Session-handoff discipline for AI coding agents: an armed cycle hook that stays armed until a cycle-matched dissolution archives it, and a dissolution gate an agent can only pass by supplying a value it could only have obtained by opening the handoff.
Two precise claims, stated narrowly on purpose. The hook does not "fire exactly once" on its own — it remains armed across sessions until a correct dissolution archives it, which is what makes an unacknowledged handoff resurface instead of vanishing. And the gate proves the agent opened the hook (it holds a value only present in the file), not that it understood or acted on it — semantic acknowledgement is a discipline you place on the agent, and the human is the one who confirms it. The gate defends the mechanical step; it does not certify comprehension.
Extracted from a live multi-agent engineering program at Tillar Technologies, where Claude- and GPT-family agents hand a project across sessions dozens of times per week. Both failure modes this repo defends against are not hypotheticals — each one happened, was measured, and is documented in the script's own header.
-
At close of business, the outgoing session writes
HOOK_NEXT_CYCLE.mdat the repo root: what is open, what is signed, where work stopped. First line declares the cycle:# Cycle 034 handoff — ... -
At session start, a hook injects that file into the next agent's context (Claude Code example below). The hook carries state only — a handoff file must never be able to mint new authority or override the project's standing rules, or your process is one poisoned handoff away from anything.
-
The agent must acknowledge the handoff to the human first, then dissolve it:
bin/dissolve_hook.sh --cycle 34 # or --digest <12+ hex of its sha256>Dissolution archives the file to
cycles/under a collision-proof timestamped name (never overwriting an existing archive), so a dissolved hook does not resurface and every cycle leaves an audit trail. Until that archival happens, the hook stays armed and reappears next session.
The argument is the point. Supplying the right cycle number or digest requires having read the hook — which is the precondition acknowledgement depends on. A bare invocation is refused and the hook stays armed for the next session.
- The ritual-follower. An agent tasked only with a narrow authoring job performed the onboarding ritual it had inherited from the project docs — and dissolved the live hook of a cycle it knew nothing about. Agents execute instructions they've merely seen. A cycle-state ritual must not be executable by an agent that hasn't engaged with the cycle.
- The worktree escape. A review agent in a disposable git worktree ran the documented absolute-path invocation — and archived the parent repository's live hook. Worktree isolation prevents accidental commits, not absolute-path writes.
- Read-proof argument:
--cycle Nmust match the hook's own first-line declaration (parsed strictly; no shell arithmetic —30+1is refused, and so is the arithmetic-expansion injection class it would open). - Tree identity: the caller must be physically inside the script's own
tree, and — if the caller's directory resolves to a git toplevel at all —
it must be this tree. Two guards on purpose: containment catches the
no-git-directory case where discovery fails silently; the toplevel
comparison catches nested worktrees/submodules that pass a pure prefix
test. Git discovery runs with
GIT_DIR,GIT_WORK_TREE,GIT_COMMON_DIR,GIT_CEILING_DIRECTORIES, andGIT_DISCOVERY_ACROSS_FILESYSTEMscrubbed, so inherited environment cannot redirect or blind it. - Non-oracular refusals: a mismatch message never reveals what would have matched. An early draft printed the expected cycle in its error — handing the next blind attempt exactly the credential it needed.
- TOCTOU narrowing: the hook's bytes are re-hashed immediately before the archive move; a hook replaced mid-flight is refused. The residual window (recheck→rename) is stated in the script rather than papered over.
- Scope limit, stated because controls must not claim more than they check: this stops the accidental dissolution — ritual-following — not a determined shell-capable adversary, who can always read the hook. Both measured incidents were accidents. Label your controls with what they actually hold.
.claude/settings.json:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command",
"command": "cat HOOK_NEXT_CYCLE.md 2>/dev/null || true" }
]
}
]
}
}Then instruct your agents (system prompt / project doc): read the hook, acknowledge it to the human, only then dissolve it — and a narrow-task subagent must never run the dissolution at all.
tests/test_dissolve_hook.sh — ten gated paths in throwaway fixtures:
bare and wrong-value refusals leave the hook armed; outside-tree and
nested-tree callers are refused; correct cycle and digest paths each archive
once; scrubbed git-environment discovery still admits the legitimate caller;
a fresh clone with no cycles/ directory still archives (the script creates
it); a same-second second dissolution does not overwrite the first archive;
and a symlinked hook is refused rather than archived as a mutable reference.
Each of the last three is a defect a cross-lineage review found in an earlier
draft, now a standing regression test.
Source-visible for review and discussion. All rights reserved; license under consideration — open an issue if you want to build on it.
Tillar Technologies — governance outside the model.