headsign workflow
headsign is a phase gate: you do the work, deterministic shell checks decide the phase transitions. You never judge for yourself whether a phase is done — the gate does.
When this skill runs inside the headsign plugin in Claude Code or Codex, the
CLI is bundled with it and no install is needed. In Claude Code,
headsign <cmd> below means:
node "${CLAUDE_SKILL_DIR}/../../dist/headsign.mjs" <cmd>
In Codex, use the absolute SKILL.md path that Codex supplies for this skill.
Go up from skills/workflow/SKILL.md to the plugin root, then invoke
dist/headsign.mjs with Node. Do not assume a session environment variable
for the skill directory; the Codex contracts checked for this release did not
confirm one.
(A PATH-installed headsign works too, and so does npx headsign once the
package is installed. Check which you have before reaching for either —
command -v headsign names a PATH copy if one exists. npx headsign with
nothing installed does not fail; it installs from the registry, at a version
npm chooses rather than the one this plugin ships, and that copy will read and
write the same .headsign/state.json the bundled one has been driving.)
If the bundled path above does not exist, this file is a copy running
outside its plugin (for example, in .claude/skills/ or .agents/skills/) — the bundle only
ships with the plugin. Use a PATH-installed headsign, or npx headsign
on the terms above; otherwise stop and tell the user to either install the
plugin or npm install the package. Do not guess at other paths.
The discipline
First, check whether this session is the driver. If this session did not run
headsign start, and hasn't been explicitly asked (by the user, or by the session that did) to continue an existing run — do not runheadsign nextorheadsign abort. A repository can have more than one coding-agent session open on it at once (a lead plus teammates, or a subagent working alongside the session that spawned it), and only the one driving the run should touch it: obeying a nudge you weren't meant to answer can burn a retry or advance a phase nobody asked you to touch. Want to know what's happening without touching anything? Runheadsign status— it's read-only, and safe to call at any time. It also prints the current phase's instructions, in the same blocknextuses. If you are delegating the work to someone who does not run headsign — a subagent, a teammate — that block is what you hand them. They cannot see the gate's requirements any other way, and requirements you paraphrase from memory are the ones that come back as a gate failure a lap later.If you are a delegated agent and were entrusted with driving a run, claim it first — don't just start calling
next. This applies when you are a teammate (Claude Code's agent-teams feature) or a subagent: you share the spawning session's process and environment, so no command you run can say who you are, andheadsign nextrecords no driver at all. Instead: runheadsign claim, then end your turn. The seal happens at your own turn end — that is the only moment headsign can learn which delegated agent you are — and the hook confirms it in its message, naming the workflow and phase. Do not runheadsign nextbefore you have seen that confirmation. If some other agent got adopted by mistake (it ended a turn while your marker was armed and could name itself), runheadsign claimagain from the agent that should be driving: a new claim re-arms the marker, and that agent is a real contender for it because its own turn end always fires the event that seals. Another agent naming itself first can take this marker too, so re-claim until the confirmation names the agent you meant. A session driving a run on its own does not needclaimat all:startstamps it as the run's mover the moment the run begins, everynextit runs re-stamps it, and while nobody has claimed the run the hook nudges that stamped session — exactly the backstop that session wants. A second session merely standing in the same directory, once the first has runstartornext, is not nudged for a run it never touched — it does not learn a run is there by being nudged about it; a run with no session on record (one begun before this behavior shipped, one driven from a terminal rather than a session, or one whose state was hand-edited) still falls back to nudging whichever session stops there. Skipping the claim fails silently rather than loudly: the run stays unclaimed, so every later nudge goes to a session — usually the idle one that delegated to you — while nothing holds your own turns at all. (Nothing records them either:unheldis written only for a stop headsign can attribute, so an unclaimed run leaves no line for your turn ends.) And if you need to check whether you are the driver, don't read it offheadsign status— it reports whether some delegated agent holds the run, never whether that agent is you. As a delegated agent, the reliable signal is the hook itself: if your turn ends are being pushed back toheadsign next, this run is yours to drive. Read which message you got: an ordinary nudge fires only on a positive match, butClaim confirmed …means an armed marker just seated you — if you did not runheadsign claim, you have taken a seat another agent was asking for, so say so and let it claim again. The test only works in this direction and only for delegated agents: ending quietly proves nothing (not having claimed, the host's already-continuing flag, an exhausted nudge cap, a pause note,HEADSIGN_OBSERVER, a directory the walk-up resolved only viaCLAUDE_PROJECT_DIR, or a run this session simply never touched while someone else was last recorded moving it, all end turns quietly), and a session gets nudged on any run nobody has claimed and nobody has yet moved, whether or not it is driving — once someone has moved it, only that session is. A nudge arrives roughly once per exchange, not once per turn end. When the hook holds a turn, the host flags the continuation, so the ending of that turn passes quietly — recorded as anunheldline in.headsign/logand onheadsign status'slast stop:line. The window is one turn wide and closes when the turn ends. A probe is not free either: one that comes back as an ordinary nudge spends one from the cap, one that passes while your own pause note is armed consumes the note, and one that lands under another agent's armed marker consumes that marker. Probe deliberately, not by habit.To begin a workflow:
headsign start, orheadsign start <name>when.headsign/holds more than one —<name>is the file's basename, soheadsign start fitnessruns.headsign/fitness.yaml. Either way it prints the first phase's instructions. Ifstartreports it cannot read.headsign/workflow.yaml, this repository names its workflows rather than keeping a default: list.headsign/and start the one you were asked for.When you have done work you think finishes the phase — or have just recovered from compaction and need to know where the run stands — run
headsign nextand obey the token on stdout's first line — merged with stderr, a progress line from the running gate may arrive first, so read stdout on its own. That one habit is the whole protocol.nextis a judgment, not a peek: it runs the phase's gate, and a failure spends one of that phase's attempts. When you only want to look, runheadsign status(rule 1) — it judges nothing and costs nothing. And when you want to know how your last turn end was handled,headsign statusis the first command to run on resuming, beforeheadsign next:nextresets the nudge counter, and the record holds only the most recent stop.RETRY→ the output shows exactly which check failed and its last output. Fix that, then runheadsign nextagain.ADVANCE→ follow the printed instructions of the new phase. IfADVANCE <phase>is followed by a line like--- gate failed: ... → routed to <phase> ---, the previous phase's gate rejected the work and routed you here — read that line, it's why you're back. A line like--- routed: when "<command>" → <phase> ---(or--- routed: default → <phase> ---) means the opposite: the previous phase passed, and itson_passroutes chose this phase; the quoted command is the condition that matched. Either way, the phase you were sent to is the one printed on stdout's first line — read the line, don't infer the move.Never end the run on your own judgment while the answer is anything other than
COMPLETE. If you are genuinely stuck — or the user asks to stop mid-run — record why withheadsign abort <reason>and report to the user; that's a legitimate exit, but it's permanent: the run cannot be resumed, and a laterheadsign startrewrites.headsign/state.jsonwhole. What it does not end is.headsign/log: the reason you type outlives the run, and so does everything logged before it. So ending a run deliberately costs the run, not its history. The rest of what it costs is nothing, and this is worth knowing before you have to decide in a hurry:state.jsonis gitignored, so ending a run leaves every tracked file exactly as it was, and the artifacts the run already wrote are untouched — committed ones by definition. One place empties, and it empties at the nextstartrather than at the abort:.headsign/tmp/, which a run begins by deleting whole, so marks and notes kept there go when the replacement run starts. What you lose is the position: the phase, the attempt counts, the walk back to here. So the only real question is how expensive this workflow's earlier gates are to pass again, which you can read off the workflow file you are holding. To pause rather than end — stepping away to resume later — write one line to.headsign/tmp/stop-notenaming what you are waiting for, and stop again. If you cannot name it, you are not blocked — runheadsign nextinstead. The stop-boundary hook passes immediately, andheadsign nextpicks the run back up later from the same phase. The hook consumes the note, so one note covers one turn end — if the wait runs over several exchanges, write it again before each turn that ends still waiting.ESCALATEmeans stop working and ask the user for direction. Some kinds end the run and some do not, so read which one you got before deciding anything —headsign statusanswers it directly, since a run that ended readsESCALATEDrather thanRUNNING. Two kinds leave itrunning, so the user can answer and have you continue from the same phase. One readsmax_total_iterations (<n>) reached: the user can raise that limit. The other readsthe workflow's rules changed under this run— the workflow file was edited while the run was walking it, which headsign allows but reports once; the user either puts the file back or tells you to runheadsign next --accept-graph-change, which accepts the change and counts it (the count is named atCOMPLETE). A barenextnever accepts it, however many times you run it — it reports the same change again and spends nothing, so do not try to get past this by asking twice. If you made that edit, say so plainly when you report it. Some edits are not reported, and silence there means "not a pinned key", never "not noticed" — so do not read it as permission you were granted, or as a report that failed. What is pinned is the rules of every phase this run can still reach, pluslimits:gate,ready,clear,on_pass,on_fail,max_attempts. A phase'sdescriptionis not — rewriting the instructions you were handed is invisible to this by design, and so are comments, formatting, and any phase the run can no longer reach. Also unreported: the contents of anything a check runs.run: "sh checks/thing.sh"pins that string, not the script, so editing that script mid-run changes what the gate decides with nothing said. If you need such a change on the record, abort and start again rather than editing under the run.The kinds that DO end the run set the status to
escalated, and nonextcontinues one. Starting over re-walks from the entry phase, so they cost whatabortcosts, arrived at by other means. There are two. One readsmax_attempts (<n>) exhausted: the phase spent its whole budget. The other readsgate failed (on_fail: escalate), which is a workflow that chose to hand the first failure of that phase straight to a person — a deliberate design, not a mishap, and one this repository's own workflows use.Starting over is
headsign starton its own. An ended run does not have to be cleared out of the way first:headsign aborton one is refused —already escalated; nothing to abort, exit 3 — because there is nothing left for it to end, and that refusal changes nothing, so a run recovered that way was recovered by thestart. Thestartrewritesstate.jsonwhole, which is also what puts every phase's attempt count back to zero.So the remaining attempts on a gate you believe cannot pass are not a reserve you are protecting by not spending them — spending them ends the run, and not spending them leaves it open with nothing recorded about why. If you have concluded a gate cannot pass, that conclusion is the thing to report to the user, immediately; do not sit on the attempts waiting for permission you were never going to get from them. Report a non-ending one and wait for direction like any other escalation — but because the run is still open, the hook will push you back to
headsign next, so write the pause note above before you stop.If the current phase's gate reads a verdict file (a review phase), spawn a reviewer subagent restricted to read-only tools (Read/Grep/Glob) and have it REPORT exactly
APPROVEDorREJECTED(with reasons). Then you write that reported verdict, verbatim, to the verdict file and runheadsign next— the reviewer stays unable to touch code or the verdict, so the judgment and the work stay separated.
Notes
- A phase's printed instruction may tell you to use a specific skill or spawn a subagent — do what it says.
headsign start/next/abort/status/claimoperate on the current directory's.headsign/only — run them from the directory that owns the workflow (the repo or git-worktree root), not a subdirectory. Each git worktree is therefore its own independent run: its state lives in that worktree's.headsign/, and a run in another worktree of the same repository neither shares it nor sees it. The stop-boundary hooks are the exception, but a bounded one: they find the run from any subdirectory of it, so drift inside the repository is harmless. Drift out of it is narrower than it used to be. The walk up from the session's own directory still stops at the first enclosing.git; if that finds no run, the hook tries once more from Claude Code'sCLAUDE_PROJECT_DIR— the project root, independent of where the session has wandered. Find a run there and the hook writes one line (unheld, detailby=CLAUDE_PROJECT_DIR) andheadsign status'slast stop:line says so — the turn is never held on this path, only recorded. Find nothing there either —CLAUDE_PROJECT_DIRunset, or naming somewhere with no run — and the hook still writes nothing anywhere, exactly as before: on that turn's own evidence it looks like a backstop that is not installed. One case stays exactly as it was, and is worth naming because it is easy to mistake for the one this just fixed: if the checkout the session drifted into has its own run, the first walk finds that one and nudges about it — a real nudge, about the wrong run. Reaching another checkout takes more than a straycd: Claude Code refuses tocdoutside the session's allowed working directories. So this needs a session that has more than one — a second directory added when it started, or added later — and it is only a risk if yours does. If a turn ends unheld and you cannot say why, checklast stop:for which of the two it names, and if this session works across more than one directory, check which one it was standing in.- Exit codes are verdicts, not errors: 1 = RETRY/PENDING, 2 = ESCALATE/ABORT.
Read the text, don't treat non-zero as a tool failure. PENDING = the gate
can't be evaluated yet — not a failure. Produce the artifact it's waiting
on (e.g. the reviewer's verdict file), then run
headsign nextagain; don't retry-loop on it. Exit 3 is different — a real usage/config error (unknown command, wrong directory, a workflow that no longer defines the current phase, anothernextalready running, or a check orready:probe that could not be run at all). Fix the invocation, the directory, or the workflow file; don't loop-retry on it. A check that could not be run is not a failing check: headsign got no exit code, so the lap moved nothing and spent no attempt — repair the command rather than the work. - You can write the workflow too, not just run it. A workflow is one
YAML file;
headsign validate --workflow <path>checks it statically — no gate runs, no state is touched — so drafting or editing one is safe at any time. A run pins the rules and not the words, which decides what you hear when you edit the file a run is walking: changegate,ready,clear,on_pass,on_fail,max_attemptsorlimitsand the nextnextreports it once before it runs the gate; change adescription, a comment, or the formatting and the run says nothing, because those sit outside the pin by design. So silence after an edit tells you which half you edited, and rule 6 above has what to do with the report when there is one. Errors (exit 3) must be fixed; warnings print to stderr and still exit 0, so a phase nothing routes to yet won't stop the run you are in. Two things a phase cannot declare: an environment (a check that needs a variable writes it into its ownrun:string, e.g.run: "FOO=bar npm test"— there is noenv:field), and "end the run here" on failure (on_failgoes as far asescalate, which stops and asks a person, and exhaustingmax_attemptsalways escalates too). On macOS,/bin/sh(bash 3.2) can mangle arun:string where a variable is immediately followed by a non-ASCII character — not just Japanese text, any non-ASCII (accents, arrows, emoji) — by eating that character's leading byte and passing a corrupted string on. Brace the variable (${var}, not$var) whenever non-ASCII text directly follows it; text earlier in the string is unaffected, and so arezsh,dash, andLC_ALL=C. - The schema is closed: a key it doesn't define is an error.
validaterejects any unknown key at any level and prints what that level allows —phase 'implement': unknown key 'max_atempts' (allowed: description, clear, ready, gate, on_pass, on_fail, max_attempts)— so a misspelled field stops the file instead of quietly doing nothing. Fix the key against the list in the message; there is no did-you-mean guess to lean on.version:must be exactly0.1, and a file written for an older schema needs its fields checked, not just its version line renumbered. - No gate can abort a run — only a person can.
ABORTis whatheadsign abort <reason>produces, so a run that readsABORTEDwas ended deliberately, by you on the user's instruction or by the user. A run headsign itself stopped always readsESCALATED. - A phase can branch to one of several phases. Its
on_passis then a list instead of a phase name: each entry has awhen:shell command and ato:, the firstwhen:that exits 0 decides where the run goes, and the last entry — the one with nowhen:— is the default. Routes are read only after the gate passes. If you are the one writing such a phase, keep everywhen:a cheap, side-effect-free predicate (typically agrepof a file the gate already checked): they run on the success path and several may run before one matches, so put the real work in the gate. Awhen:that cannot run at all — bad command, timeout — stops the run with exit 3 rather than guessing a destination; fix the command. on_fail: retryandon_fail: <this same phase>are not the same thing.retrystays in the phase: you keep working on the same failure, with the files that phase produced left where they are. Naming the phase itself leaves and re-enters it, which printsADVANCEand runs that phase'sclear:(deleting the files it lists). Re-entering is right when starting the phase fresh is the point — a stale review verdict has to go — and wrong when the work should simply continue. What re-entry does not reset ismax_attempts. That count is failures of the phase since it last passed, so a phase you leave on a failure and come back to — by naming itself, or through another phase, or around a longer route — resumes the count where it stopped, andheadsign statusshows it asattempt n/maxwhile the run is in that phase. A budget of 2 is spent by two rejections however many phases sat between them.headsign statusis a different kind of command, on purpose: it never judges, so its first-line vocabulary is separate fromnext's tokens —RUNNING/COMPLETE/ESCALATED/ABORTED, capitalized like a report, notADVANCE/RETRY/PENDING/ESCALATE/ABORT. Its exit code doesn't follow the 1=RETRY/PENDING, 2=ESCALATE/ABORT rule above either: it's 0 whenever state could be read at all (evenESCALATED/ABORTED), and 3 only when there's no run to read. Use it whenever you want to look without the risk of touching anything — see the discipline's first rule, above, for when that's required rather than optional.- A
startthat reports a run already in progress asks you a question, andheadsign nextis the command that answers it. The refusal names the phase and both moves. Continuing is the cheaper one, and it is also the probe: it runs that phase's gate and names the first check that is not satisfied, which is how you find out whether the phase's work has been done at all. Read that phase in the workflow file before you call, because the gate is what you are about to spend: aready:that has not passed answersPENDINGand spends nothing at all, and a gate holding a full build spends that build. Once the gate does run, a failure costs one attempt of that phase and one iteration towardmax_total_iterationswhere the workflow sets one, and while that phase'son_failis the defaultretryit deletes nothing — the run stays where it stood, with its files where they are.headsign statusanswers a neighbouring question rather than this one: it runs no check, so it reports the phase and when the run entered it, and what has been produced inside that phase is a fact about the tree that only the gate reads. Ending the run instead costs the position, and costs everything under.headsign/tmp/as well — the nextstartempties that directory whole. - Lock contention from parallel subagents is normal — wait briefly and retry once; the error message itself carries the recovery.