Reply checker: one block names every unproven sentence at once (part 3 on PR 416) - #433
Open
EdbertChan wants to merge 11 commits into
Conversation
Bare `UNVERIFIED:` silenced every check in the message it appeared in. It
cost four characters and bought a whole turn, so it got used to stop
checking rather than to report a check that could not run.
Six hooks read that marker, each with a private regex, and they had
drifted: hedge-runs-prove-it required a named blocker, diu-stop did not.
engine/hooks/_markers now holds one definition of
{{CAT-UNVERIFIED: <claim> -- cannot verify: <reason>}}, the names-a-blocker
test, and the retired bare marker. install.sh links it beside the hooks.
Every suppressor reads it: diu-stop, external-claim-gate (which borrows
diu-stop's matcher at detect.py:13), hedge-runs-prove-it,
history-claim-check, incidence-needs-repetition, named-verb-guard and
prove-it-ship-gate. A well-formed tag excuses its paragraph. A tag that
names no blocker excuses nothing. Bare UNVERIFIED: is prose, and the block
text names the tag that replaced it.
Same three messages through diu-stop, on 509b9cd and on this commit:
legacy marker in front of a real claim
before exit=2 contains an `UNVERIFIED:` claim...
after exit=2 unverified-shaped claim ("because")...
well-formed tag beside the claim
before exit=2 contains an `UNVERIFIED:` claim...
after exit=0 (silent)
tag naming no blocker
before exit=2 contains an `UNVERIFIED:` claim...
after exit=2 unverified-shaped claim ("because")...
Two bugs found by tests while building this, both now pinned:
- A hook directory is itself a symlink, so joining ".." onto it resolves
the link first and lands beside the checkout. Two textual dirnames reach
the sibling module; test_installed_layout builds the symlinked layout.
- Block texts are .format() templates, so a literal {{CAT-UNVERIFIED}}
rendered as {CAT-UNVERIFIED}. Every existing test passed through it,
because they asserted the substring CAT-UNVERIFIED. The tag is now a
format argument, and _markers tests assert on the braces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VKsvxJk65w6q7KnPSRYvNg
diu-stop and prove-it-ship-gate both returned on stop_hook_active before any check ran. For diu-stop the reason was real -- trimming words reveals more words to trim, and nine consecutive blocks on one 150-word message were observed -- but returning early passed the whole message. The first block of a turn bought a free pass for whatever the rewrite said next, including a claim that was never checked. That is what happened in the session behind this change. The same message, sent as a retry, on 509b9cd and on this stack: before (509b9cd) exit=0 (silent) after exit=2 `UNVERIFIED:` is no longer an escape hatch -- it reads as ordinary prose and the claim beside it is judged on its own... A diu-stop retry now skips only the word count. prove-it-ship-gate has no word count, so its bypass was a pure free pass and is gone. These checks cannot loop the way the word count did: a well-formed tag always passes and every block message names it, so there is always a move that ends the turn. test_naming_the_blocker_ends_the_turn and test_naming_the_blocker_ends_the_turn_on_retry pin that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKsvxJk65w6q7KnPSRYvNg
… one block from the reply checker name every unproven sentence in the reply, not just the first. Review claim: One block from the reply checker names every unproven sentence at once, quoting each sentence, so fixing what the block says always clears it. Review lane: behavior Safety invariant: A reply that passes today still passes and a reply that is blocked today is still blocked; only the block's wording changes, and it now names every flagged sentence instead of the first one. Effectiveness measurement: The two-claim reply from the repro gets one block naming both sentences; after both are fixed, the second try with stop_hook_active true exits 0. Slice rationale: Part 3 of the stack on PR 416. PR 416 made rewrites face the proof check, which is what lets the same block come back; this part makes one block carry every sentence. It is kept apart from PR 416 because it changes what the block says, a different claim from when the check runs. Architectural effect: claude_stop_check.py gains a function that returns every flagged sentence; the block message is built from all of them. The existing single-result function keeps returning the first result, so its callers are unchanged. Goal: Claude never gets the same block twice for one reply because the first block left a sentence out. Motivation: The user's rule is that each block must tell Claude everything it needs to do to get through. The current code returns on the first hit in find_unverified_claim (engine/hooks/diu-stop/claude_stop_check.py lines 154-175 at commit ae767ea). Alternative considerations: Folding this into PR 416 was ruled out by the user, who asked for a stacked PR. Reporting only a count of flagged sentences was ruled out because Claude needs the sentence itself to fix it. Implementation details: In engine/hooks/diu-stop/claude_stop_check.py add find_unverified_claims(message), which walks every paragraph exactly the way find_unverified_claim does today and returns one (trigger phrase, sentence) pair for each paragraph that has no evidence. find_unverified_claim returns the first pair's phrase, or None. main() builds one block that names every flagged sentence as a numbered item, quoting the sentence (cut to 120 characters) and its trigger word, then says once how to fix any of them. New cases go in engine/hooks/diu-stop/tests/test_hooks.py. Non-goals: No change to what counts as a claim, the tag format, the word limit, or the ship-claim hook (engine/hooks/prove-it-ship-gate/detect.py line 171 already clears on any one tag or evidence anywhere in the reply, so it cannot block twice for two claims). Layer: domain Feature state: active Files: engine/hooks/diu-stop/claude_stop_check.py, engine/hooks/diu-stop/tests/test_hooks.py Change types: - engine/hooks/diu-stop/claude_stop_check.py: modify - engine/hooks/diu-stop/tests/test_hooks.py: modify Acceptance criteria: - A reply of two paragraphs, "The owner crashed because the lock never released." and "The deploy was stale because the cache never cleared.", exits 2 and stderr contains both sentences. - The same reply with a well-formed tag in each paragraph, sent with stop_hook_active true, exits 0 with empty stderr. - The same reply with a tag in the first paragraph only, sent with stop_hook_active true, exits 2 and stderr contains the second sentence but not the first. - python3 engine/hooks/diu-stop/tests/test_hooks.py exits 0. - python3 engine/hooks/diu-stop/tests/test_fix_matrix.py exits 0. Solution: Make one block from the reply checker name every unproven sentence in the reply, not just the first. Review claim: One block from the reply checker names every unproven sentence at once, quoting each sentence, so fixing what the block says always clears it. Review lane: behavior Safety invariant: A reply that passes today still passes and a reply that is blocked today is still blocked; only the block's wording changes, and it now names every flagged sentence instead of the first one. Effectiveness measurement: The two-claim reply from the repro gets one block naming both sentences; after both are fixed, the second try with stop_hook_active true exits 0. Slice rationale: Part 3 of the stack on PR 416. PR 416 made rewrites face the proof check, which is what lets the same block come back; this part makes one block carry every sentence. It is kept apart from PR 416 because it changes what the block says, a different claim from when the check runs. Architectural effect: claude_stop_check.py gains a function that returns every flagged sentence; the block message is built from all of them. The existing single-result function keeps returning the first result, so its callers are unchanged. Goal: Claude never gets the same block twice for one reply because the first block left a sentence out. Motivation: The user's rule is that each block must tell Claude everything it needs to do to get through. The current code returns on the first hit in find_unverified_claim (engine/hooks/diu-stop/claude_stop_check.py lines 154-175 at commit ae767ea). Alternative considerations: Folding this into PR 416 was ruled out by the user, who asked for a stacked PR. Reporting only a count of flagged sentences was ruled out because Claude needs the sentence itself to fix it. Implementation details: In engine/hooks/diu-stop/claude_stop_check.py add find_unverified_claims(message), which walks every paragraph exactly the way find_unverified_claim does today and returns one (trigger phrase, sentence) pair for each paragraph that has no evidence. find_unverified_claim returns the first pair's phrase, or None. main() builds one block that names every flagged sentence as a numbered item, quoting the sentence (cut to 120 characters) and its trigger word, then says once how to fix any of them. New cases go in engine/hooks/diu-stop/tests/test_hooks.py. Non-goals: No change to what counts as a claim, the tag format, the word limit, or the ship-claim hook (engine/hooks/prove-it-ship-gate/detect.py line 171 already clears on any one tag or evidence anywhere in the reply, so it cannot block twice for two claims). Layer: domain Feature state: active Files: engine/hooks/diu-stop/claude_stop_check.py, engine/hooks/diu-stop/tests/test_hooks.py Change types: - engine/hooks/diu-stop/claude_stop_check.py: modify - engine/hooks/diu-stop/tests/test_hooks.py: modify Acceptance criteria: - A reply of two paragraphs, "The owner crashed because the lock never released." and "The deploy was stale because the cache never cleared.", exits 2 and stderr contains both sentences. - The same reply with a well-formed tag in each paragraph, sent with stop_hook_active true, exits 0 with empty stderr. - The same reply with a tag in the first paragraph only, sent with stop_hook_active true, exits 2 and stderr contains the second sentence but not the first. - python3 engine/hooks/diu-stop/tests/test_hooks.py exits 0. - python3 engine/hooks/diu-stop/tests/test_fix_matrix.py exits 0.
…ack PR preflight against PR 416's branch. Review claim: The make-pr preflight passes on the change. Review lane: proof Safety invariant: Proof-only; adds no product behavior. Effectiveness measurement: Preflight prints "ok preflight passed" and exits 0. Slice rationale: One proof step for the repo's own publication gate. Architectural effect: None; verification only. Goal: Prove the change meets catstack's own PR gates, including hook test coverage. Motivation: The catstack make-pr overlay requires preflight to pass before publishing. Alternative considerations: Running each gate by hand was ruled out; preflight runs the gates for the touched paths. Implementation details: Run make-pr preflight with PR 416's branch as the base. Non-goals: No product edits here; proof only. Layer: app_regression Feature state: active Exit code: 0
…ly checker's fix-matrix test file on the finished change. Review claim: The reply checker's fix-matrix test file passes on the change. Review lane: proof Safety invariant: Proof-only; adds no product behavior. Effectiveness measurement: test_fix_matrix.py exits 0, so every known fix still clears its block. Slice rationale: One proof step for the one review claim. Architectural effect: None; verification only. Goal: Prove the new wording did not break any known fix. Motivation: The fix matrix pins that each documented fix clears the checker. Alternative considerations: None; this file is the existing guard for fixes. Implementation details: Run the diu-stop test_fix_matrix.py file. Non-goals: No product edits here; proof only. Layer: app_regression Feature state: active Exit code: 0
…ly checker's main test file on the finished change. Review claim: The reply checker's main test file passes on the change. Review lane: proof Safety invariant: Proof-only; adds no product behavior. Effectiveness measurement: The test file includes the two-sentence cases and exits 0. Slice rationale: One proof step for the one review claim. Architectural effect: None; verification only. Goal: Prove every flagged sentence appears in one block. Motivation: The new cases pin the user's rule that a block names everything needed to get through. Alternative considerations: Running the full run_all_tests.sh was ruled out as slower than needed for a change confined to one hook. Implementation details: Run the diu-stop test_hooks.py file. Non-goals: No product edits here; proof only. Layer: app_regression Feature state: active Exit code: 0
…rix/g0.t0.a-ac3c1a13d-307665af
…t/g0.t0.a-a5d864102-199f5c63
…k that no temporary handoff files are left on the branch before the PR merge gate. Review claim: The branch carries no leftover handoff files. Review lane: proof Safety invariant: Read-only; never deletes files or changes the index. Effectiveness measurement: scripts/scrub-handoff-artifacts.sh exits 0 on the finished branch. Slice rationale: Required terminal gate for implementation plans. Architectural effect: None; verification only. Goal: Keep temporary files out of the PR. Motivation: Invoker tasks can leave handoff files that must not ship. Alternative considerations: None; this is the standard terminal gate. Implementation details: Run scripts/scrub-handoff-artifacts.sh without --apply. Non-goals: No edits. Layer: app_regression Feature state: active Exit code: 0
…ad2f8fcb9-09adfe05 — Read-only check that no temporary handoff files are left on the branch before the PR merge gate. Review claim: The branch carries no leftover handoff files. Review lane: proof Safety invariant: Read-only; never deletes files or changes the index. Effectiveness measurement: scripts/scrub-handoff-artifacts.sh exits 0 on the finished branch. Slice rationale: Required terminal gate for implementation plans. Architectural effect: None; verification only. Goal: Keep temporary files out of the PR. Motivation: Invoker tasks can leave handoff files that must not ship. Alternative considerations: None; this is the standard terminal gate. Implementation details: Run scripts/scrub-handoff-artifacts.sh without --apply. Non-goals: No edits. Layer: app_regression Feature state: active
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_0464ffb6-9477-4885-a9c7-3d184f27fabd) |
4 tasks
Owner
Author
Owner
Author
|
Mergify repair stopped: required check failed: test. The retry cap was reached for current head 0a07aff. |
EdbertChan
force-pushed
the
stack/EdbertChan/cat-unverified-tag/2-retry-checks-evidence
branch
from
September 12, 2026 06:18
ae767ea to
e34b32f
Compare
EdbertChan
changed the base branch from
stack/EdbertChan/cat-unverified-tag/2-retry-checks-evidence
to
main
September 12, 2026 06:19
Contributor
|
Tick the box to add this pull request to the merge queue (same as
|
…ED tag and file:line skip Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013sYZswUQ3HEnUEdVWrUbvq Change-Id: I8bfc252b9dffd54dd9e30a22df49abbb773a2670
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_0e324690-ddc2-481e-a83b-cd23fe6ac01a) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The reply checker now lists flagged sentences from every paragraph in one block, so Claude can address them in one rewrite.
Previously, it stopped at the first claim without proof. Fixing that paragraph could reveal another block with the same message.
Each numbered item quotes a sentence, capped at 120 characters, and names its trigger. Shared guidance explains how to add proof or a valid tag.
Review Claim
One block reports a flagged sentence from every paragraph lacking proof; tagging both paragraphs in the two-claim repro clears the next check.
Review Lane
behavior
Review Unit
engine-runtime
Safety Invariant
Replies that passed still pass, and replies that were blocked remain blocked. Claim detection, evidence exemptions, marker validation, and word-limit behavior stay unchanged; only claim diagnostics expand.
Slice Rationale
Part 3 stacks on PR #416, targeting
stack/EdbertChan/cat-unverified-tag/2-retry-checks-evidence. That PR checks rewrites; this slice changes what a block reports. The hook and its regression tests form one review unit.Alternative considerations: the user requested a separate stacked PR; a count alone would not tell Claude which sentences need proof.
Assumptions: scope and safety invariant follow the supplied workflow. Detection remains paragraph-based, with one reported match per paragraph.
Non-goals
No changes to claim rules, tag format, evidence scope, word limits, rewrite-check activation, or the ship-claim hook. This does not enumerate multiple claims within a single paragraph.
Architecture
Before
Detection returned the first offending phrase and stopped.
After
find_unverified_claims()collects a trigger and sentence from each flagged paragraph, andmain()formats the list into one block.The existing
find_unverified_claim()interface still returns the first phrase orNone, preserving its callers.Test Plan
Test Plan
python3 engine/hooks/diu-stop/tests/test_hooks.py— 81 tests,OK, exit 0. Covers both sentences appearing together, both tags clearing a rewrite, only the remaining sentence being reported, and 120-character truncation.python3 engine/hooks/diu-stop/tests/test_fix_matrix.py— 5 tests,OK, exit 0.python3 engine/skills/make-pr/scripts/preflight.py --base origin/stack/EdbertChan/cat-unverified-tag/2-retry-checks-evidence— exit 0; output:bash scripts/scrub-handoff-artifacts.sh—scrub-handoff-artifacts-ok, exit 0.Revert Plan
Revert Plan
git revert 3c6948d.Note
Low Risk
Claim detection and pass/block outcomes are intended to stay the same; only block-message diagnostics change, aside from redundant duplicate imports that add no behavioral risk.
Overview
The Claude Stop hook’s unverified-claim feedback now reports every flagged paragraph in a single block instead of stopping at the first trigger phrase.
find_unverified_claims()walks the message in order, still using the same paragraph-level detection rules, and returns a (trigger, sentence) pair per hit. Each quoted sentence is extracted around the match and truncated to 120 characters so stderr stays readable.find_unverified_claim()remains a thin wrapper over the first hit for existing callers. When the hook blocks, stderr lists numbered sentences plus their triggers and asks for proof or a{{CAT-UNVERIFIED}}tag per paragraph, so one rewrite can address all outstanding claims.Tests cover multi-claim blocks, partial fixes on retry (
stop_hook_active), and sentence truncation.Note: The diff also duplicates the
sys.pathsetup andimport markersblock inclaude_stop_check.py(lines 56–64); that looks accidental and worth cleaning up separately.Reviewed by Cursor Bugbot for commit 60e9619. Bugbot is set up for automated code reviews on this repo. Configure here.