feat(phase3-B): Stop-gate enforcement, three-outcome routing, bounded retry + instrumentation - #34
Merged
Merged
Conversation
) 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
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.
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 inhooks/hooks.json)Fires on every turn end, reads
.cyclaudes/pending-ui/<sid>.jsonand.cyclaudes/verify-result/<sid>.json, and routes the three outcomes:outcome == "fail"→ block; reason = the expected-vs-actualdetail, 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 abstaindetailto the user (viasystemMessage). 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_blockproves it does not block.The gate is satisfied only when
coveredcoversui_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'sat) so early runs can be audited for swallowed abstentions.The hook is stdlib-only and self-contained (no dependency on
cyclaudesbeing importable from the hook process). Its core is factored intodecide(payload, project_dir)with a thin stdin/stdout/exit__main__.Verify-result writer —
cyclaudes verify(src/cyclaudes/verify_result.py)Chosen over a pytest-
sessionfinishhook 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_resultare 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_activere-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