fix(handoff): let leaf turns reach the human; report contract at exit - #555
Merged
eduralph merged 1 commit intoSep 16, 2026
Merged
Conversation
An interactive leaf (Plan, sign-off, publish, Act) could not hand a turn back to the human while its exit contract was still open. The handoff guard was registered as a Claude Code Stop hook, and Stop fires at the end of every turn, not when the session ends. Its exit 2 sends the guard's text to the model instead of the human, so a leaf that asked a question got the guard's complaint back, answered it, and was blocked again. Nothing bounded the repeat: one sign-off session was blocked twelve turns in a row, and the pressure on the model was to write the artifact the guard wanted, which for sign-off is the human's own decision. Turn ends are no longer intercepted. The rendered settings register no Stop hook, and the guard with no arguments now exits 0 without output, so an instance whose settings kept the old registration through a copier update cannot get stuck either. The contract is judged once, when the driver reaps the leaf's process, and the result is printed to the human on stderr. That report only informs: it writes nothing, blocks nothing and never raises. The report follows a few rules so it cannot hide anything: - a recorded abandon reason is printed first, then every open problem; - a blank abandon reason counts as no reason; - a bundle whose check fails is listed as "could not check" and the other bundles are still checked; - an unreadable [[doctor.checks]] table in pdca.toml is reported once per planner reap, while the other brief checks still run; - non-printable characters are escaped, so text a session wrote cannot use terminal escapes to hide the lines after it. The /handoff self-check and --abandon behave as before (only the --abandon reply text changed). The prompts, the /handoff command body and docs/01-render-and-integrate.md no longer promise hook enforcement. Tests that drive a session over an open contract now capture the report, so the driver suite prints nothing. The hook and handoff module came in with 900d638. Fixes #534 Signed-off-by: eduralph <eduard@ralphovi.net>
This was referenced Sep 16, 2026
eduralph
marked this pull request as ready for review
September 16, 2026 15:43
eduralph
deleted the
fix/534-handoff-contract-reported-at-reap-not-turn-end
branch
September 16, 2026 16:21
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
User impact: in an interactive Plan, sign-off, publish or Act session, a question the
assistant asks you may never reach you. Until the session's required files are written,
every time the assistant finishes a turn it is sent a "you may not end yet" message and
keeps answering that instead of waiting for you. One sign-off session looped like this
twelve turns in a row, and the pressure is on the assistant to write the very file it is
waiting for you to decide — for sign-off, your accept/iterate decision.
This PR stops checking at every turn end. The session's required files are checked once,
when the session exits, and anything missing is printed for you to read; nothing is
blocked.
Reported in #534.
What to look at
the checking script does nothing when called the old way.
stop it from hiding anything (an abandon reason never hides the problem list, one broken
bundle never hides the others, a broken dependency table is reported, terminal escape
characters are shown escaped).
/handoffcommand and the render-and-integrate docno longer claim a hook enforces the contract at turn or session end.
To try it: start an interactive sign-off session, ask the assistant a question before
writing a decision, and check that the turn comes back to you. Then quit without a
decision: the driver prints a
handoff: the signoff session ended; checking its exit contract found …report naming the bundle and the missingsignoff-decision.Root cause
handoff_guard.pywas registered as a Claude Code Stop hook, which fires each time themain agent finishes a turn, not when the session ends; while the contract was open it
exited 2, and on exit 2 Claude Code feeds stderr back to the model rather than handing the
turn to the human. The Stop envelope's
stop_hook_activeflag was parsed and discarded, sonothing bounded the loop.
Fix
hooks.Stopentry is removed fromtemplate/.claude/settings.json._stop_verdict()is removed fromtemplate/.claude/hooks/handoff_guard.py; the no-argument path returns 0 with no output,so an instance that kept the old registration through
copier updatecannot loop. Anunknown mode now gets a usage error (exit 2) instead of falling through, so a typo like
--abandnno longer silently records nothing.handoff.session()'sfinallycalls a newreport_at_reap(), whichprints the abandon reason (if any) and then every item
stop_problems()finds, to stderr.It writes no bundle file, moves nothing, and never raises: output goes through
_emit,which tolerates an unwritable stderr, and the scratch-file read and unlink are guarded
(a deeply nested JSON scratch file used to raise
RecursionErrorout of thefinally).stop_problems()no longer returns[]whenabandonedis set;_abandon_reason()is the single reader, and a blank value is no reason.
try, and a failure becomes<bundle>: could not check (<error>)._doctor_table_problem()reads[[doctor.checks]]once per planner reap. If thetable is broken, or
pdca.tomldoes not parse, the report gets one line naming thefile, the table, the error, and that the dependency clause was not checked. Briefs are
still checked with the new
check_planner(..., dependencies=False)./handoffstillcalls
check_bundlewithout that keyword, so its behaviour is unchanged._printable()escapes every non-printable character in every reap line.leaves.py(_plan_prompt,_plan_batch_prompt,_signoff_prompt,_signoff_batch_prompt,_publish_prompt), comments inrun_signoff,run_signoff_batch,run_publishanddo_plan_batch, the/handoffcommand body, the--abandonreply anddocs/01-render-and-integrate.mdnow say/handoffis thesession's self-check and the driver reports an unmet contract when the session ends. The
stale comment in
do_plan(leaves.py:916-917onmain) is left alone on purpose sothe pending split children never get the #301 plan-advisory pass — do_plan reviews the bundle it was handed, not the briefs the session wrote #480 change still applies; it is a developer comment, not text the model
reads.
template/tests/test_handoff_reap.py.test_handoff.pyinverts the"Stop hook is registered" and "abandon hides the list" assertions. Tests in
test_handoff.py,test_publish_slice.pyandtest_state_resolved.pythat end a sessionwith an open contract now capture stderr, so the driver suite prints no
handoff:lines.Out of scope, tracked separately: re-reading the briefs a CSV/default batch Plan session
wrote (#549),
/handoffin a rendered instance (#508), Act vs/handoff(#528).Verification
Line numbers on
mainare at6ba00ba; "after" means this branch.template/.claude/settings.json:60-71onmainheld the Stopregistration;
template/.claude/hooks/handoff_guard.py:48-73onmainreturned 2 foran open contract, and the Stop envelope was discarded at
:53. After: nohookskey;the no-argument path is
handoff_guard.py:77-79.test_handoff_reap.pyNoTurnEndIsInterceptedruns the hook in-process andonce as a real subprocess, spelled the way the old registration ran it. It first
confirms the contract check is live in that environment (
--checkexits 1), so aconfig that fails to load can't pass the test by accident. Fails on
main, passeshere.
discharged contract prints nothing.
template/src/pdca_harness/handoff.py:306-311onmainprinted only anabandon reason. After:
session()finallyathandoff.py:396-412,report_at_reap()at:415-458.ReportedAtReapcovers a missingsignoff-decision,iterate-dowith norationale, and a brief with an empty Success criterion. Fails on
main, passes here.handoff.py:444-458(onetry),_emitat:317-324, scratch-fileguards at
:403-412.ReportOnlychecks that the bundle is unchanged, that the leaf's ownexception still passes through, and the nested-JSON and directory-scratch cases.
handoff.py:374-375onmainreturned[]for any abandon. After:_abandon_reasonat:282-292; per-bundletryat:556-561; doctor-table read at:327-350, called fromstop_problemsat:547-555;check_planner(dependencies=)at
:102-141.ReportHidesNothingcovers the abandon-then-list order, a blank abandon, anon-UTF-8 decision file and a directory where a file belongs (in both registration
orders), four broken-table shapes, an unparseable
pdca.toml, and one table lineacross the id-batch, single-Plan and CSV sessions. It also confirms
/handoffstillfails as before on a broken table. Fails on
main, passes here._printableathandoff.py:303-314, applied in_emit.NothingPrintedRawuses an abandon reason carryingESC[8m, a newline, aforged
- issue_7: all clearitem and U+202E, plus a dependency token with ESC quotedfrom a brief. Every reap in the file also asserts no raw control characters.
ModelFacingTextPromisesNoEnforcementruns one pattern over the/handoffcommand body, every contract-role prompt plus
_act_prompt, and the hook's--check/--abandonreplies. A self-check makes sure the pattern still matches everyretired sentence word for word, plus four rewordings.
/handoffand--abandonare unchanged apart from the reply text.SelfCheckAndAbandonUnchanged;run_checkis untouched.zero
handoff:lines. The regression check was also run with the fix's non-test changesreverted:
test_handoff_reapandtest_handofffail there and pass with the fix. Therender/update-compat suites, the docs lint and the site link check all pass.
Fixes #534