Skip to content

Reply checker: one block names every unproven sentence at once (part 3 on PR 416) - #433

Open
EdbertChan wants to merge 11 commits into
mainfrom
plan/reply-checker-one-block-names-every-unproven-sentence-at-once-part-3-on-pr-416
Open

Reply checker: one block names every unproven sentence at once (part 3 on PR 416)#433
EdbertChan wants to merge 11 commits into
mainfrom
plan/reply-checker-one-block-names-every-unproven-sentence-at-once-part-3-on-pr-416

Conversation

@EdbertChan

@EdbertChan EdbertChan commented Sep 11, 2026

Copy link
Copy Markdown
Owner

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, and main() formats the list into one block.

The existing find_unverified_claim() interface still returns the first phrase or None, 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:

unit engine-runtime: 2 file(s)
declare Review Unit: engine-runtime
gate python3 scripts/check_hook_test_coverage.py engine/hooks/diu-stop
check_hook_test_coverage: OK (1 hook(s) checked)
ok preflight passed

  • bash scripts/scrub-handoff-artifacts.shscrub-handoff-artifacts-ok, exit 0.

Revert Plan

Revert Plan

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.path setup and import markers block in claude_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.

edbert-bot and others added 10 commits September 11, 2026 02:24
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
…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
@cursor

cursor Bot commented Sep 11, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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)

@EdbertChan

Copy link
Copy Markdown
Owner Author

Mergify repair stopped: lowest open stack PR #433 is based on stack/EdbertChan/cat-unverified-tag/2-retry-checks-evidence, which still belongs to open PR(s) #416 outside this stack; leaving it alone to avoid dropping dependency changes

@EdbertChan

Copy link
Copy Markdown
Owner Author

Mergify repair stopped: required check failed: test. The retry cap was reached for current head 0a07aff.

@EdbertChan
EdbertChan force-pushed the stack/EdbertChan/cat-unverified-tag/2-retry-checks-evidence branch from ae767ea to e34b32f Compare September 12, 2026 06:18
@EdbertChan
EdbertChan changed the base branch from stack/EdbertChan/cat-unverified-tag/2-retry-checks-evidence to main September 12, 2026 06:19
@mergify

mergify Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

…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
@cursor

cursor Bot commented Sep 12, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot 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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants