Skip to content

feat(phase3-B): Stop-gate enforcement, three-outcome routing, bounded retry + instrumentation - #34

Merged
CameronCrow merged 2 commits into
mainfrom
feat/32-stop-gate
Jul 22, 2026
Merged

CameronCrow merged 2 commits into
mainfrom
feat/32-stop-gate

Conversation

@CameronCrow

Copy link
Copy Markdown
Owner

Closes #32

Phase 3, deliverable B — the deterministic enforcement and loop integration that lets an agent verify its own UI work and refuses to let it finish while UI changes are unverified. Built to the FROZEN INTERFACE in planning/PHASE_3.md; the state-file schemas are unchanged.

Stop hook — hooks/stop_gate.py (registered in hooks/hooks.json)

Fires on every turn end, reads .cyclaudes/pending-ui/<sid>.json and .cyclaudes/verify-result/<sid>.json, and routes the three outcomes:

  • UI touched, no covering verify-result → block; reason names the files to verify.
  • outcome == "fail" → block; reason = the expected-vs-actual detail, so the agent self-corrects and re-verifies.
  • outcome == "pass" covering the touched set → allow.
  • outcome == "abstain" covering the touched set → allow, AND escalate the abstain detail to the user (via systemMessage). This is the load-bearing rule: abstain is a legitimate stopping point. If it blocked, an unverifiable change would thrash into Claude Code's 8-consecutive-block cap and false-pass there — the exact failure the phase exists to prevent. test_abstain_allows_and_escalates_does_not_block proves it does not block.

The gate is satisfied only when covered covers ui_touched; a later edit to a new UI file re-opens it (block again, naming just the new file).

Idempotent / stop_hook_active-aware / 8-block-cap-safe: a turn with nothing pending allows silently (never nags); a bounded retry (default 3, CYCLAUDES_RETRY_CAP) caps correct→verify cycles well under 8, and on exhaustion it escalates — allows the stop plus a clear "could not satisfy the UI checks after N attempts" message — rather than looping invisibly.

Instrumentation: pass / fail / abstain / block / escalate counters persist at .cyclaudes/counters.json (outcomes de-duped by the result's at) so early runs can be audited for swallowed abstentions.

The hook is stdlib-only and self-contained (no dependency on cyclaudes being importable from the hook process). Its core is factored into decide(payload, project_dir) with a thin stdin/stdout/exit __main__.

Verify-result writer — cyclaudes verify (src/cyclaudes/verify_result.py)

Chosen over a pytest-sessionfinish hook so an ordinary pytest run never writes a result — only an explicit verification does. Emits the frozen schema {session_id, outcome, covered, detail, at}. The outcome mapping reuses the existing exit-code semantics exactly (cyclaudes.abstain / pytest_plugin): 0 → pass, EXIT_ABSTAINED (12) → abstain, else → fail — so a real failure is never reclassified as an abstention or vice versa. map_exit_code / classify / write_result are importable and unit-tested directly.

Tests (tests/test_stop_gate.py, tests/test_verify_result.py)

Every branch against tmp_path state files: block-when-unverified (names files), block-on-fail (reason carries the diff), allow-on-pass, allow-and-escalate-on-abstain (does not block), stop_hook_active re-entry does not thrash, a new UI file after a pass re-opens the gate, retry exhaustion escalates rather than blocking forever, foreign/malformed result never satisfies the gate, and the writer produces the frozen schema for each of pass/fail/abstain.

Local run: python -m pytest tests/ -p no:cacheprovider → 179 passed, 6 deselected (pip install -e . not run per the issue).

Note

Sibling issue #31 (PostToolUse flagger) also adds hooks/hooks.json; my Stop entry is localized, so an add/add rebase on that one file is expected and fine.

🤖 Generated with Claude Code

)

Phase 3, deliverable B — the deterministic enforcement and loop integration.

A `Stop` hook (hooks/stop_gate.py, registered in hooks/hooks.json) reads the
frozen `.cyclaudes/pending-ui/<sid>.json` and `verify-result/<sid>.json`
schemas and routes on every turn end:

- UI touched, no covering verify-result -> block, reason names the files.
- outcome == "fail" -> block, reason = the expected-vs-actual detail.
- outcome == "pass" covering the touched set -> allow.
- outcome == "abstain" covering the touched set -> allow AND escalate the
  detail to the user. Abstain satisfies the gate — the load-bearing rule; if it
  blocked, an unverifiable change would thrash into the 8-block cap and
  false-pass there.

The gate is satisfied only when `covered` covers `ui_touched`; a new UI file
after a pass re-opens it. Idempotent (a turn with nothing pending allows
silently), stop_hook_active-aware, and 8-block-cap-safe via a bounded retry
(default 3, CYCLAUDES_RETRY_CAP): on exhaustion it escalates (allow + a "could
not satisfy after N attempts" message) rather than looping invisibly. pass /
fail / abstain / block / escalate counters persist under `.cyclaudes/` for
auditing swallowed abstentions.

The verify-result writer ships as `cyclaudes verify` (src/cyclaudes/
verify_result.py), reusing the existing exit-code semantics exactly — 0 -> pass,
EXIT_ABSTAINED (12) -> abstain, else -> fail — so a real failure is never
reclassified as an abstention or vice versa.

The hook core (`decide`) and the writer (`map_exit_code`, `classify`,
`write_result`) are importable and tested per-branch against tmp_path state
files, including the proof that abstain does not block and that retry
exhaustion escalates rather than blocking forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	hooks/hooks.json
#	planning/TODO.md
@CameronCrow
CameronCrow merged commit 6d4d32c into main Jul 22, 2026
1 check passed
@CameronCrow
CameronCrow deleted the feat/32-stop-gate branch July 22, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(phase3-B): Stop-gate enforcement, three-outcome routing, bounded retry + instrumentation

1 participant