flywheel protocol v1
This is the protocol flywheel enforces today, in code — not the fuller factory model it is
building toward. The authority for everything below is the code itself:
internal/flywheel/events.go (the kinds map and Validate), internal/flywheel/state.go
(Derive’s status transitions), internal/flywheel/verify.go (flywheel verify: rules T1, T3,
T4, T5, T8, R1, W1 and P1), internal/flywheel/land.go (flywheel land enforces T7 and T9 live)
and internal/flywheel/chain.go (the hash-chained log, checked by flywheel verify --log, with a
chain per shard in the sharded layout). docs/design/autonomous-shipping.md describes a larger
design — audits, nonconformances, andon signals, a hash-chained log — and much of it is built now
(audited and signal events, first-article audits, the chain); §3 below says exactly which parts
of that design are still aspiration (T2, T6 and parts of T9 and T10), so a reader never has to
guess.
Every record is one JSON line appended to .flywheel/events.jsonl by AppendEvent, and the log is
never rewritten — flywheel log (or a command that calls AppendEvent internally) is the only way
to add a line. flywheel state derives .flywheel/state.json and the status block in
flywheel.md from the log alone (Derive): the log is the one source of truth, everything else is
a read-only projection of it. A command whose --dir is inside a unit’s worktree
(<root>/.flywheel/worktrees/<task>, made by flywheel run --worktree) uses the main checkout’s
ledger at <root>, never the worktree’s stale copy (issue #395).
Every skill in skills/ that drives this loop cites protocol v1 and links back here;
cmd/flywheel/docs_test.go fails the build the moment a skill stops citing it, or this file’s
first line stops matching ^# flywheel protocol v.
Contents
- 1. Required entries per task
planneddispatcheddispatch_refusedworktree_setupstartedworker_planno-planoff-coursefinishedreportreviewedreview_findingfinding_responsegroup_reviewedblockedlostwithdrawnrebasedrecoveredlandedland_correctedexceptedallow_untriagedauditedrelease_auditedhealthsuspendedunsuspendedprobedgate_probedamendedvalidatedowns_checkedinspectedstaffedsession_startsession_commandsession_endlearningnotelead_editgoal
- 2. Transitions the code enforces
- 3. Designed, not enforced
- 4. Who may write which event
- Log layout
- 5.
flywheel verifyand exit codes
1. Required entries per task
Forty-three event kinds exist; events.go’s kinds map is the authority for the list, and
Validate rejects anything else. A stamped event’s ts is strictly after the previous event in
its log (issue #650), so replay never sorts a re-plan before the finish it follows. Nine of the kinds
carry a task’s status (state.go’s kindRank orders them, together with some status-neutral
kinds, for replay); the rest — worker_plan,
no-plan, off-course, report, validated, owns_checked, amended, sharded and the
others — change other fields but never the status itself. staffed, goal, session_start,
session_command and session_end carry no task at all, and Validate also refuses a task
on health, release_audited, reanchored and recovered (events.go).
planned
- Written by: the planner or lead, via
flywheel log --task <id> --kind planned --brief <path> [--session S --model M] [--goal G] [--note TEXT]. - Carries:
task,brief(the brief file’s path),header(the parsed brief header — owns, needs, needs-state, needs-env, preflight, gates, live-gates, exclusive, review, kind and sha256 — as recorded when the event was appended;Kindis the optionalkind:line, issue #475, trimmed and lowercased, the last one winning, which routing andflywheel stats --by model --kindread andflywheel lintchecks againstlint.kindsin config, defaultfeature,fix,refactor,test,docs,chore,perf; an emptykind:line or another value is a lint problem, and no kind is ever inferred;flywheel lintalso warns on a gate running a JavaScript test runner outsidelint.test_runners, else the runners package.json’s test scripts and dependencies name, issue #646), and reports a problem for a gate whose command word is not a command (placeholder text; words listed inlint.gate_commandsare always accepted, issue #662) or that is a placeholder phrase wrapped in(...)or<...>such as(as the brief), on any host,persona(planner),sessionandmodel(the planner’s identity, from--session/--model),goal_id(from--goal; an unknown goal is refused with exit 1 and nothing is appended),note, andissue(the tracker issue the plan links to, set byflywheel brief --from-issue, issue #457;Validateaccepts it only on aplannedevent and only >= 1).owns/needsare copied from the brief header when the event is appended. Whenheaderis present it is authoritative over the brief file, andowns/needsare its summary. flywheel brief <task> --from-issue N [--repo OWNER/REPO] --owns a,b [--needs t1,t2] [--gate CMD]... [--kind K] [--force] [--no-plan] [--dir DIR](issue #457) reads issue N withgh issue view N --json number,title,body,url, writes.flywheel/briefs/<task>.txtatomically (the header from the flags — needs defaultnone, gates defaultgo build ./... && go vet ./... && go test ./...when--dirhasgo.mod, otherwise--gateis required — then# TASK: <title> (issue #N),Issue: <url>,## Why (from the issue)with the issue body, and## Checks), refuses an existing brief without--force, lints it (a lint problem removes the file), prints the path and, unless--no-plan, appends this planned event withissueN, printing the warningsflywheel log --kind plannedprints. Exit 0 ok, 1 error (gh, write, lint), 2 usage (missing--from-issue, a bad N, missing--owns).- Effect:
Derivesets statusplanned. Verify’s T1 (plannedBriefOnly) uses the task’s latestplannedevent’s brief path, deliberately ignoring anyamendedevents, as the hash a fresh dispatch must match. Acceptance criteria belong to the goal (flywheel goal add --accept CMD), not to a unit: a planned event links to its goal withgoal_id. Re-planning an id that already hasdispatchedattempts starts a new plan:flywheel logwarns on stderr (exit 0;--replansilences it, and is a usage error with any other kind), andDeriveresets the row’s session, attempt, rc, reason, verdict, model and stale list so the floor shows a cleanplannedrow. The attempt count and every earlier event stay, so the nextflywheel runnumbers after the old attempts. Branches are shared by every worktree of a repository, so when branchfw/<task>already existsflywheel logalso warns that another flywheel root may own the id (issue #479):warning: branch fw/<id> already exists (checked out in <path>); another root may own this id (--replan silences this), the parenthesised path only when a worktree other than this root’s own task worktree has it checked out. It runs read-only git only, and none outside a repository. Take a duplicate plan back with awithdrawnevent.flywheel run --worktreeon such an id fails naming that worktree.
dispatched
- Written by: the CLI only, via
flywheel run <task>— never by hand. - Not written when a variable the prompt’s
needs-env:names (a correction’s unioned with the base brief’s) is unset or empty in flywheel’s environment: run refuses with ruleneeds-env(exit 6) naming the variables, never their values, before dispatch (only adispatch_refusedevent is recorded, issue #534);flywheel validaterefuses the same way (exit 6) before any gate runs. - Not written when a
preflight:command exits non-zero: eachpreflight: CMDline the prompt names (a correction’s unioned with the base brief’s) runs in order in the repository root, as a gate runs, after the needs-env check; the first that exits non-zero or cannot start refuses with rulepreflight(exit 6) naming the command, its exit code and its first output line, before any file is written (only adispatch_refusedevent is recorded, issue #635). Needs-env and preflight run before the dispatch lock is taken, so a slow preflight never holds up another dispatch; under the lock the prompt must still be the bytes they checked, else the run errors and must be started again (issue #651). Each passing command prints<task> preflight ok: <cmd>. Onlyflywheel runruns preflight;flywheel validatedoes not (it measures the deliverable, not capacity). - Not written when a gate the attempt will be measured with (a correction’s
gate:lines when it declares any, else the base brief’s) has a command word that is not a command, or is placeholder text likegate: (as the brief)by lint’s placeholder-phrase rule: run refuses with rulegate-command(exit 6) naming the gate and word, before the dispatch lock (only adispatch_refusedevent is recorded, issue #662). No gate is run; words in configlint.gate_commandsare always accepted. - Carries:
task,attempt(r1,r2, … for a fresh run;c1,c2, … for a correction),adapter(one of the four the code accepts:opencode,claude,codexor the offlinesim;AdapterForinadapter.gorejects any other name),worker(the resolved worker’s name, issue #469; omitted on events recorded before it),lead(the lead session that dispatched it:flywheel run --session ID, default$FLYWHEEL_SESSION, issue #472; omitted when unset and on older events, never required),variant(the worker’s reasoning variant, issue #473; omitted when unset and on older events),route(the routing choice, issue #474:{model, pick, objective, draw, kind, basis, scores[{model, attempts, score}]},pickexploitorexplore;kindthe task’s briefkind:(issue #475, omitted when it has none) andbasisthe scoreboard the choice used:kindwhen at least one candidate has enough attempts and a score on that kind’s own rows, elsemodel(the model-wide rows, as for a task without a kind);scoresare the rows used; omitted when the worker has noroutingblock, when--modelwas given, on a resume, and on older events),model(flywheel costcharges eachfinishedevent to the model on its own attempt’sdispatchedevent, falling back to the task’s latest preceding one when the attempt has none;flywheel stats --by modelscores every adapter, model and variant from these events, and a rate whose denominator is under 3 isnullin JSON andn/ain text;--by model --kindadds the same scoreboard per task kind,by_model_kindin--json, each row’skindthe kind of its task’s latestplannedoramendedheader),path(the run file),sha256(of the exact prompt sent),brief(for a correction attempt:.flywheel/briefs/<task>.<attempt>.delta.txt, the per-attempt snapshot of the delta taken atomically at dispatch; the operator’s--deltafile is left untouched and may be edited or reused for the next correction without breaking this one’s T1, issue #452; a failed snapshot fails the dispatch),header(the parsed brief header of the exact prompt dispatched — the planned brief on a fresh attempt, the delta on a correction — authoritative over the file it names),baseline(paths already dirty at dispatch, so a later owns check can excuse pre-existing dirt it didn’t cause),base(the commit HEAD pointed at in the worker’s tree when the attempt was dispatched, so the owns check can count changes the unit’s own commits since then, issue #332;flywheel run <task> --worktree --base REFbranches a newfw/<task>from REF’s commit instead of the main checkout’s HEAD, without checking REF out, sobaserecords REF’s commit — an existingfw/<task>that does not contain REF is refused with aflywheel rebase <task> --onto REFhint, and--basewithout--worktreeis refused (exit 6, rulebase) before dispatch (only adispatch_refusedevent is recorded), issue #456; without--basea newfw/<task>starts fromorigin/<integration.branch>, else the localintegration.branch, when that is configured (neither resolving refuses the dispatch), else from HEAD with a progress warning when HEAD carries commitsorigin/main(elseorigin/master, else the local main or master) lacks, issue #550),workdir(the worker’s tree, canonical absolute form, recorded only when it is not the flywheel root: the task worktree forrun --worktree, or PATH forflywheel run <task> --workdir PATH, an existing tree the lead prepared, a merge in progress say, used as it is with no setup, checkpoints or attempt commit;--workdirwith--worktree, a PATH that is not a directory, or one that is not a git working tree of the same repository (a differentgit rev-parse --git-common-dir) is refused (exit 6, ruleworkdir; with--base, rulebase) before dispatch (only adispatch_refusedevent is recorded);validateandinspectdefault to it, and a--resumeor--deltawith neither flag runs there again, issue #545),increment(N whenflywheel run --increment Nsent only increment N of the brief as a fresh session; the attempt is an ordinaryr<n>; 0 or omitted means the whole brief;Validateaccepts it only on adispatchedevent and only >= 1, andflywheel runrefuses (exit 6, ruleincrement) a brief that defines no increment N — an “Increments” section with item N, or an “Increment N” heading),note. - Effect:
Derivesets statusdispatched, incrementsAttempts, and fixes this as the task’s current attempt — every laterstarted,worker_plan,report,finished,validated,owns_checkedorlostevent whose ownattemptdiffers is stale and ignored (listed underStaleinstead of changing anything). Verify’s T1 (ruleT1) checks this event’ssha256against the current brief (fresh attempts) or the delta file it names (c*attempts); anamendedevent between the dispatch and now excuses a fresh attempt’s mismatch, but never a correction’s.
dispatch_refused
- Written by: the CLI only, via
flywheel run <task>(issue #651), when the run returns beforedispatchedbecause of a rule refusal (every “refused (exit 6, rule …)” above:needs-env,preflight,gate-command,base,in-flight,owns,exclusive,limits,budget,breaker, …) or because the dispatch lock could not be taken (.flywheel/dispatch.lockheld past its wait). Appended throughAppendEvent(events.lock), never under the dispatch lock. Not written for a resume with no worker session, for a refusal because the factory is suspended (rulesuspended: the owner froze the ledger, and a retry loop would write to it while stopped), for a plain error (bad arguments, an unreadable file), for a task with noplannedevent, or when the task’s newest event is already adispatch_refusedwith the same rule and note (a retry loop records the refusal once). - Carries:
task,rule(the refusal’s rule, ordispatch-lockfor the lock timeout) andnote(the refusal text; for the lock,lock ... is held by run <task> (pid <n>) (waited ..., <n> handovers); ...— the holder named from the lock file’scmdlabel, oranother commandfor an older lock file).Validaterequires the task and the rule; no other kind may carryrule. - Effect: no status change: the unit stays
planned(or whatever it was).Derivesets the task’srefusedin.flywheel/state.jsonto<rule>: <note>of the newestdispatch_refusednewer than its newestdispatched; a laterdispatchedclears it.flywheel statuslists each such unit underRefused: <n>as<task> <status> refused: <rule>.
worktree_setup
- Written by: the CLI only, via
flywheel run <task> --worktree(issue #430), after the task’s worktree (.flywheel/worktrees/<task>) exists and beforedispatched, on every dispatch that has something to prepare ((link),(copy)or(install)entries,worktree.carry, or a setup command): the effective brief’sneeds-state: <path> (link)entries, each linked from the repo into the worktree (a directory junction on Windows, a symlink elsewhere; a path already present is left alone), then theworktree.setupcommand, run with bash (the gates’ shell) in the worktree withFLYWHEEL_TASK,FLYWHEEL_WORKTREEandFLYWHEEL_ROOTset and killed atworktree.setup_timeout(default10m). Setup runs on every dispatch, so it must be idempotent.flywheel rebase <task>also writes one, with no attempt, when it re-runsworktree.setupafter a rebase (issue #672; seerebased). A relative script path (the first word, or the second afternode/python/bash/sh/pwsh) missing from the worktree but present in the root resolves against the root; prefer"$FLYWHEEL_ROOT/<script>". On Windows gates and setup run in Git for Windows’ bash, never the WSL launcher. - Carries:
task,attempt(the attempt being dispatched),linked(the linked paths),command,rc,duration_ms,note(the last 20 lines of output, or the link error),escaped(issue #460: the entries of a linked path, one level deep plus one level inside each@scope, that are links or junctions resolving into the main checkout outside the linked path itself and outside the worktree, e.g. a workspace’snode_modules/@acme/web -> packages/web; the.pnpmstore and broken links are not),copied(issue #471: theneeds-state: <path> (copy)entries plusworktree.carry, copied from the repo into the worktree after the links and before setup, overwriting on every dispatch; the paths only, never their contents),installed(issue #460: theneeds-state: <path> (install)entries) andinstall(the package manager’s offline install run once in the worktree for them, after the copies and before setup, with setup’s runner andworktree.setup_timeout, picked by the lockfile at the worktree root:pnpm-lock.yaml→pnpm install --offline --frozen-lockfile,bun.lock/bun.lockb→bun install --frozen-lockfile,yarn.lock→yarn install --immutablewith.yarnrc.ymlelseyarn install --frozen-lockfile --offline,package-lock.json/npm-shrinkwrap.json→npm ci --prefer-offline --no-audit; orup to date (<lockfile>)when.flywheel/install.sha256in the worktree, written after an install exits 0, holds the lockfile’s name and SHA-256 and every installed path is a directory).rc,duration_msstay the setup command’s; an install failure’s exit and tail go innote. - Effect: no status change. An
(install)path that is also a(link)entry (a linked tree is shared and must never be installed into), no lockfile at the worktree root, or an install that does not exit 0 refuses the dispatch with rulesetupand an event whosenotesays why; setup does not run. A copy whose source is missing in the repo, or whose path git tracks in the worktree (a copy would overwrite committed content; use(link)or commit it), refuses the dispatch with rulesetupand an event whosenotenames it; a copied path git does not ignore in the worktree printswarning: needs-state copy <path> is not git-ignored in the worktree .... A link error (a(link)path missing in the repo) or a setup that does not exit 0 refuses the dispatch (exit 6, rulesetup, the fix naming the path or command and the output tail): nodispatchedevent is recorded and no worker starts. A non-emptyescapedprintswarning: needs-state link <path> holds links into the main checkout (<n>: ...)on the dispatch’s stderr, since the unit’s gates would import the main checkout’s copies (useneeds-state: <path> (install)instead); withworktree.strict_linkstrue (defaultfalse) it also refuses the dispatch (rulesetup, the event still recorded withescapedand anotesaying it was refused, and setup does not run).
started
- Written by: the CLI, from the run’s first parsed
startobservation. - Carries:
task,session(the emitted OpenCode session id). Nomodel— that rides only ondispatchedandfinished. - Effect:
Derivesets statusrunning. Verify’s T4 (ruleT4) treats any session that ever wrote a task’sstarted,finished,dispatched,reportorworker_planevent as a worker session — one aninspectedevent must never reuse.
worker_plan
- Written by: the CLI, the first time the run’s text output has a line that, after stripping leading whitespace, list/quote markers (-, , +, >, #) and markdown emphasis (, _,
), starts withPLAN ` (issue #284). - Carries:
task,attempt(set on everyworker_plansince issue #592; older events have none and are matched by their path),path(.flywheel/runs/<id>.<attempt>.plan.md),sha256of that file. The recorded plan is the text from that matched line onward, with leading whitespace and list markers stripped but emphasis markers preserved. - Carried over on
--resume(issue #592): a resumed session is the same conversation and never repeats its first message, so when an attempt of the session being resumed already checked in, the CLI appends, before the stream starts, aworker_planfor the new attempt with the latest such plan’spathandsha256(the plan file is not copied),attemptthe new attempt andnote“carried over from(resumed session <first 8 chars of the session>)". - Effect: no status change. Its presence before step 20 is what a missing
no-planevent certifies.
no-plan
- Written by: the CLI, at most once per attempt, at the 20th completed step, or at finish (a
normal or stalled end after at least one completed step, before that
finishedevent), only if neither aPLAN-prefixed line (as detected above) nor an earlierno-planhas appeared (issue #65, #284, #533). Not written on a--resumewhose session already checked in: that attempt carries the session’sworker_planover (issue #592). Ends with no worker output (silent, start failed) write none. Each one is followed by ano-plansignal. - Carries:
task,attempt; the finish-time one also carriesnote(“finished after N steps with no PLAN check-in”). - Effect:
Deriveignores it for status exactly likeworker_plan; it never changesrcorreason— it is a flag, not a verdict.
off-course
- Written by: the CLI, at most once per attempt, the moment a
read,greporglobtool call (offCourseTools) names the 5th distinct path outside the worktree — library source a worker reached for instead ofgo doc(issue #72, #154). - Carries:
task,attempt,note(the offending paths, in the order first seen, comma-joined and capped at 200 characters byclipNote). - Effect:
Derivehas no case for it, so — likeworker_planandno-plan— it never touchesStatus; it is a record for a human (or a future gauge) to notice, not a verdict.
finished
- Written by: the CLI, exactly once per attempt, on every code path out of a run (clean stop, silent, stalled, provider error, output cap, start failure).
- Carries:
task,session,attempt,model(everyfinishedevent carries the model, issue #134),rc,reason(stopclean;lengthoutput-capped;error;rate-limited— the provider’s rate or usage limit cut the run off (a claude 429 or limit message); the note nameslimit resets <time>, no signal is recorded, the breaker does not count it, andflywheel runresumes the same session after the reset (limits.rate_limit_retries,limits.rate_limit_max_wait), issue #380. A rate-limited finish whose reset parses also carriesreset_at(RFC 3339): the limit belongs to the subscription, so until then the model is paused —flywheel runrefuses a fresh attempt on it (exit 6, rulerate-limit), while a--resumewaits for the reset plus a minute before dispatching (bounded bylimits.rate_limit_max_wait; a wait beyond it is refused, exit 6, rulerate-limit, naming the reset), and a--resumewith no delta file after a rate-limited or abandoned-job finish uses the automatic continue delta (the next unused<task>.limit-<n>.txt), issue #472,flywheel nextHOLDs withrate-limit: <model> paused until <time>, and the floor shows the unitrate-limited until HH:MMand an andon entrymodel/<model>paused until HH:MM; a later cleanstopfinish on the model ends the pause early, issue #383. A claude stream’srate_limit_eventlines carry the exact reset epoch and the share of the window used: a rate-limited finish takesreset_atfrom the latest one (else the parsed message), and EVERY finish that saw one carrieslimit_utilization(0..1),limit_reset_at(RFC 3339) andlimit_window(e.g.five_hour). Pause before the wall: when a model’s LATEST finish haslimit_utilizationat or abovelimits.rate_limit_pause_at(default 0.95; negative disables) andlimit_reset_atis ahead, the model is paused until then exactly as for a hit limit, the refusal naming(<n>% of the <window> window used)and the andonpaused until HH:MM (<n>% used); a later finish below the threshold, or the reset passing, releases it, issue #417;abandoned-job— a clean stop that left a background shell it started (claude Bashrun_in_background, whose tool_result reportsrunning in background with ID: <id>oragentId: <id>) never collected — no later tool call’s input names that id (BashOutput,KillShell, aReadof its output file, …) — so the job died with the session; the note namesbackground job never collected: <cmd>, no signal is recorded, the floor shows the unitabandoned-jobon the andon, andflywheel runresumes the same session once, immediately, with.flywheel/briefs/<task>.job-1.txttelling the worker to run the job in the foreground; a secondabandoned-jobis returned as is, issue #390;suspended— aflywheel suspend --stopstopped the worker at a lease tick; the note isstopped by suspend at <ts>, the session is kept andflywheel resumecontinues it (seesuspended), issue #572;start-failed;silent;stalled— the run-file gap watchdog killed a run that had started but stopped producing lines for the worker’s stall timeout, issue #158),note,steps,tokens,cost,peak_reasoning(the largest single-step reasoning figure seen in the run, omitted from the line when 0, issue #156),sha256(of the whole run file),wrote(the attempt’s distinct edit/write paths, sorted, at most 50, omitted when the attempt made no edits, issue #163; it also holds the worktree-relative paths the worker changed in the tree since dispatch — changed at finish and not dirty at dispatch, or with a sha that differs from the dispatch baseline,.flywheel/excluded — so MultiEdit, NotebookEdit and shell writes count, issue #463),wrote_from_tree(the subset ofwrotethat came only from the tree, not from a tool observation; omitted when empty, issue #463),commands(the shell commands the worker ran, in order, at most 100, each clipped to 300 characters; omitted when it ran none, issue #365),gates_unrun(on astopfinish only: the ids1,2, .. of the attempt’s effectivegate:lines that no recorded command contains, whitespace collapsed, or contains the gate’s first 40 characters; live gates are not checked; omitted when empty, issue #365),commit(on astopfinish of a--worktreeunit only, issue #391: workers never run git write commands, so flywheel commits the attempt itself onfw/<task>— built in a temporary index from HEAD plus every changed path inside the attempt’s owns — the resolved brief’s owns plus this dispatch’s own prompt’sowns:lines, so a correction delta that widens owns is committed (issue #477), the same set validate measures; checkpoints use it too — flywheel’s own bookkeeping excluded, message<task> <attempt>with aFlywheel-Task: <task>trailer, author and committerflywheel <flywheel@localhost>, the branch moved by compare-and-swap, and the worktree’s index refreshed for the committed paths; omitted when nothing owned changed. Changed paths outside owns stay uncommitted, are named on the note asleft uncommitted (outside owns): <paths>, printed as `warning:: attempt commit left changed path(s) uncommitted (outside owns): ` and recorded as `uncommitted`; a commit failure never fails the run and is noted as `attempt commit failed: `). uncommitted(issue #477): the changed paths the attempt commit left out. Only afinishedevent may carry it. While any of the task’s latestfinishedevent’suncommittedpaths is still changed (dirty or untracked) in the tree validate measures, the owns check fails with the single outside entryleft uncommitted by the attempt commit: <paths>(replacing those bare paths, and even when a baseline or claim would excuse them): a PR cut fromfw/<task>would lack them.reasonis the provider’s own finish reason, passed through verbatim by the adapter rather than normalized by flywheel;stopis the only clean value. Other values seen in practice:length,error,start-failed,silent,stalled(above) andunknown— unknown meaning the provider reported no reason the adapter recognised, which is information, not a bug (issue #176).- Effect:
Derivesets statusfinished.stageOf(factory.go) then readsreason:stop(or empty) is stagefinished;lengthis stage cut-off; anything else,stalledincluded, is stage failed — both cut-off and failed units reach the andon andflywheel status’s Attention list (issue #131).peak_reasoningchanges no stage: the factory floor (render.go) prints it next to a capped unit’s state, and alengthfinish’s own progress line names it in the hint suggesting smaller steps (run.go). classifyRun(factory.go) readswrotealongsidereason: a done attempt with a non-stopreason and a non-emptywroteclassifies run state failed-dirty instead of plainfailed(orcapped, whenreasonislength) — a failed attempt that left files behind, needing a human decision (revert, resume, or re-dispatch) that a clean failure or a cut-off run that wrote nothing does not (issue #163).failed-dirtyreaches the andon and is dead, exactly asfailedis.- A
stopfinish is not automatically done (issue #364): when the claude result line carriedpermission_denials,noteaddspermission denied: <tool [path], ...>and apermission-deniedsignal follows thefinishedevent (run state blocked). The andon is raised live when the stream reports the denial as it happens (issue #526): claude’suserline whosetool_resulthasis_errortrue and text containing `requested permissions to use` appends the attempt's one `permission-denied` signal at once, with `note` `permission denied (live): `, and prints `andon: permission-denied (live)`; the worker is not stopped, and no second signal is appended at finish. A Bash denial names the deny pattern and the command segment it matched, `Bash: ( )`, or `Bash: unattributed ( )` when no pattern matches (issue #497). Otherwise, when `wrote` is empty, no signal is recorded (an untriaged signal blocks landing, and some units legitimately write nothing): the factory view derives the floor state **no-writes** from the `finished` event alone. An attempt that resumed the same agent session as an earlier attempt that wrote files is not no-writes: it wraps up that work (issue #592). Both states apply only while the unit is awaiting judgement (status `finished`); once passed, rejected or landed it shows done. Both reach the andon; no-writes blocks nothing. - A non-empty
gates_unrunaddsgates never run by the worker: <ids>tonoteand prints a<task> <attempt> never ran gate(s) <ids>progress line; no signal is recorded (the lead re-measures every gate).flywheel validateprints `note: the worker never ran gate(s) itself; its report's claims about them are unmeasured` after the gate lines (issue #365). - Workers never write git (issue #423). The guarantee is two layers that do not depend on PATH:
denied at dispatch — the claude adapter’s default
--disallowedToolsdeniesgitcommit, push, stash, reset, checkout, rebase, merge, add, rm, mv, restore, update-index, apply, tag, branch (sogit branch --show-currenttoo; usegit rev-parse --abbrev-ref HEAD), switch, cherry-pick, revert, am, worktree, clean, notes, replace, update-ref and gc — and detected after each attempt — on every exit path, before flywheel’s own attempt commit (so its index refresh is never charged to the worker), flywheel compares the worktree’s HEAD, branch, stash, index (git diff --cached --name-only) and tags with the state captured at dispatch, andnotenames what changed:HEAD: <old> -> <new>,branch: <old> -> <new>,stash,index: staged <paths>,index: unstaged <paths>,tags: +<name>/tags: -<name>, each followed by who it is charged to and why:(the worker's: <why>)or(changed by another process: <why>)(issue #621). git-write blames a worker only for git writes the worker could have made. A staged path, an unstaged path while HEAD stayed put, a tag added or moved onto a local-only commit, or a deleted tag is agit-writesignal whatever the guard logged (issue #423). A HEAD or stash move, or a branch change that moved HEAD, is one only when the git guard logged a write the worker tried (issue #361; otherwise another process moved it and the note says so). What only another process makes never signals, even beside a logged write: a tag on a commit a remote-tracking ref contains (a fetch, shared by every worktree; the note saysfetched, issues #442, #621), the worktree’s branch renamed with HEAD unchanged, and an unstaged path when HEAD moved. Other branches andrefs/remotes/*are not compared. The guard logs refused writes only: the read forms ofgit config(--get*,--list/-l,get,list, a single key with no value) pass it (issue #621). Paths the worker staged are unstaged by flywheel (git reset -q -- <paths>, content kept in the working tree) and the note adds `index restored:`. The PATH git guard is defence in depth, not the guarantee: a shell that puts the real `git` first on PATH never reaches it. checkpoint(issue #422): on an unclean finish (error,rate-limited,stalled,silent,abandoned-job,length,suspended) of an attempt that wrote files, the sha of the snapshot of its changed owned paths atrefs/flywheel/checkpoints/<task>/<attempt>(seerecoveredbelow). Only afinishedevent may carry it.- Workers load no MCP servers unless the worker config lists them (issue #425). Every claude
dispatch passes
--strict-mcp-configwith--mcp-configset to the worker’smcpvalue (the Claude CLI’s{"mcpServers": {...}}shape, compacted), or to the empty set{"mcpServers":{}}whenmcpis unset — so the user-level MCP servers (mail, calendar, drive, trackers, chat, plugins) that--setting-sources userwould still load never reach a worker. The review agent sets nomcpand gets the empty set.flywheelrejects a config whosemcpis not a JSON object with anmcpServersobject. - A claude worker’s
permission_mode(issue #526) is its--permission-mode:acceptEdits(the default when unset),bypassPermissions,default,planordontAsk; any other value, or the key on a non-claude worker, is a config error; set it withflywheel config set workers.<name>.permission_mode bypassPermissions.--disallowedToolsis passed under every mode,bypassPermissionsincluded (Claude Code enforces deny rules even when bypassing), so the git-write deny list still holds.flywheel lintwarns when a brief asks for web research (WebSearch, WebFetch, “web search”, “search the web”, “web fetch”) and the default worker is a claude worker whoseallowed_toolslack WebSearch/WebFetch and whose mode is notbypassPermissions. - A claude worker’s
--max-turns(issue #459) is itsmax_turns, elselimits.max_turns, else 200; a negative value, or a workermax_turnson a non-claude worker, is a config error; set it withflywheel config set workers.<name>.max_turns N(0 clears it).
report
- Written by: the CLI, only when the attempt’s
reasonisstopand its last text was non-empty. - Carries:
task,path(.flywheel/runs/<id>.<attempt>.report.md),sha256. - Effect: no status change. Any other finish reason keeps the last reply as
.flywheel/runs/<id>.<attempt>.partial.mdon disk instead, and appends noreportevent — so a cut-off or failed run can never be mistaken for a done one.
reviewed
- Written by:
flywheel review <task> --verdict pass|correct|reject --session S [--model M], from an isolated copy of the tree; by the review agent,flywheel review <task> --agent --session S [--worker NAME] [--round N](issue #389);flywheel log --kind reviewedremains valid input. - Carries:
task,verdict(pass,correct,reject, orcrashed— a panel member whose run failed twice, only with acategory, issue #469 — enforced byValidate),session,model(the reviewer’s identity),tree,note,persona(reviewer). The review agent’s event also carriesadapter(the agent that reviewed; a verdict passed in by hand names none, and the next agent round is one more than the task’sreviewedevents that do), verdictcorrectwhen any finding is a blocker or major andpassotherwise, the note<n> finding(s): <b> blocker, <m> major, <k> minor, andtokensandcost: what every reviewer run of the round spent (the first run and a retry after a refused answer), summed from the stream the way a worker’s are (issue #459). Itsreview_findingevents carry none, so the spend counts once; a hand verdict carries neither.flywheel cost,limits.unit_cost_usd,limits.budgetand the floor count areviewedevent withcostortokensas spend. - Effect:
Derivemapspass→passed,correct→needs-correction,reject→rejected, except that areviewedevent written by the review agent (personareviewerwith anadapter) never setspassed— itspassleaves the status unchanged, because the agent reads and the gauges measure: only validate+inspect (or a hand-recorded review, which re-runs the gates) pass a unit (issue #389). Unlikeinspected, verify’s T8 does not restrict who may write areviewedevent —inspected(§4) is the path every current command actually takes.
review_finding
- Written by: the review agent only,
flywheel review <task> --agent --session S(issue #389). The agent (the staffingreviewerrole’s adapter and model, else the default worker, or--worker) runs in the unit’s worktree under the git guard with a read-only tool policy (claude:Read,Grep,Glob,git diff/log/show,go vet/test, read-onlygh issue view/gh pr view, the unit’s own gate commands asBash(<program> <first argument>:*)patterns (a bare program such asnodenever), and anyreview.allowed_toolspatterns (issue #469);Edit,WriteandNotebookEditrefused), on a prompt holding its instructions, the unit’s brief followed by every correction delta dispatched since the unit’s latest fresh attempt (or its latest planned/amended brief, when later), in order (issue #458; the deltas share 48 KB, the latest always whole and earlier ones replaced oldest first by a line naming their path), the attempt’s gate readings and the diff from the dispatch base (capped at 200 KB), kept at.flywheel/reviews/<task>.<round>.prompt.mdbeside its stream.flywheel/reviews/<task>.<round>.jsonl. The findings contract is checked, not trusted: an answer whose file does not exist (and is not a changed path), whoselineis not 0 or a real line, with an empty claim or scenario, or with more than 3 nits is refused, and the agent runs once more, fresh, into<task>.<round>b.jsonl; a second refusal records nothing andflywheel review --agentexits 1 naming both transcripts (their spend is in the transcripts only; a panel member records it on its crashed event).flywheel review calibrate --cases FILE --session Sruns the same agent over a sample of past PR states in temporary worktrees and synthetic ledgers (never this ledger) and reports its recall against the case file (docs/calibration/README.md). - Carries:
task,attempt(the unit’s latest),session(the reviewer, never a worker session of the task: refused T4),model,tree,severity(blocker,major,minorornit),category,title(the claim),observed(the failure scenario),ask(the fix hint),path(the file),line_no(the file line;lineis already the product line), andfinding, a stable id<task>-r<round>-<n>.Validaterequires the task, session, a severity in the set, the title and the path. - Effect: no status change. Every finding and the round’s closing
reviewedevent go out in oneAppendEventswrite; an answer without a parsable{"findings": [...]}block records nothing. - Open and closed (
OpenFindings, issue #389): a finding opens when it is raised. It closes only on the framework’s evidence, never on an agent’s word: a LATER agent review round (areviewedevent with personareviewerand an adapter) completes without re-reporting it — samepathand the same claim, case- and space-insensitive; a re-report is still open under its new id — or the lead dismisses it (afinding_responsebelow). A worker’sfixednever closes a finding by itself.blockerandmajorfindings block the unit.
finding_response
- Written by:
flywheel review <task> --agent --fix(ReviewLoop, issue #389) after each correction, one per open blocking finding it sent — the worker’s answer parsed from the attempt’s report (.flywheel/runs/<task>.<attempt>.report.md), under the worker’s session; or, for an id the report does not answer,disputedwith notemissing: the worker gave no answer, so the next round re-reviews it anyway. Responses are recorded only for the findings sent: a finding outside the unit’s effective owns is never sent and never gets one (issue #458). And by the lead,flywheel review <task> --dismiss <id> --session S --note WHY:disputedwith notedismissed: WHY; a worker session of the task is refused (T4, exit 6), and the id must be a finding the task’s reviewer raised. - Carries:
task,attempt(the correction),session,finding(the id it answers),verdictfixedordisputed, andnote(the worker’s evidence or reason).Validaterequires the task, the finding and a verdict in the set. - Effect: no status change. Only a lead dismissal —
disputed, note startingdismissed:, from a non-worker session — closes a finding, and with it any later re-report of the same file and claim. - The loop: review; no open blocking finding → verdict
pass(exit 0). Else flywheel writes the findings delta.flywheel/briefs/<task>.review-<round>.txt— the effective brief’sowns:,needs:and everygate:line,# TASK: fix the review findings, one block per open blocking finding (FINDING <id> [<severity>] <file>:<line> — <claim>,scenario: …,fix hint: …), then the contract: fix each finding, change nothing unrelated, re-run every gate, and end the report with one line per finding,FINDING <id>: fixed <evidence>orFINDING <id>: disputed <reason>— resumes the worker’s session on it, records the answers and reviews again. After--roundsreviews (default 3) the open blocking findings are printed and the command exits 1. Without--fix-workerthe corrections go to the worker that built the unit: the lastdispatchedattempt’sworkerwhile it is still configured, else the first configured worker with itsadapterandmodel, else the default worker with one stderr line saying so (issue #469). A session never crosses adapters:flywheel run --resumeonto a worker whose adapter differs from the last attempt’s is refused (exit 6, ruleresume) before dispatch (only adispatch_refusedevent is appended), and the fix loop instead dispatches such a correction as a fresh session that reads the delta. - The thread (issue #389):
.flywheel/reviews/<task>.mdis GENERATED from the event log (RenderReviewThread) after every agent review round, every recordedfinding_responseand every dismissal — never on GitHub. It holds one `## Round— , , , tree ` section per round listing its findings, each with its current status (`open`, `closed in round `, `re-reported in round as `, or `dismissed by : `) and the answers under it (` : fixed|disputed — `), then `## Open blocking findings`. It carries a `` marker; a file there without it is never overwritten, and a failed write is only a progress warning. - Enforced:
flywheel inspect --verdict passis refused (rulereview, exit 6) while the task has an open blocking finding, andflywheel verifyfails rule R1 for aninspectedpass recorded while one was open (§2).flywheel floorshows such a unit asreview-open (<count>)on the andon, andflywheel statsreports a review block. Open blocking findings outside the effective brief’s owns show asneeds-owner (<count>)instead, andreview-opencounts only those inside (issue #458). - The review panel (issue #420):
flywheel review <task> --agent --panel --session S [--round N] [--fix [--rounds N] [--fix-worker NAME] [--worktree]]runs the review agent once per member ofreview.panel, sequentially, each a persona owning one dimension. A member’s reviewer is its adapter/model inreview.panel, else its worker, elsestaffing.reviewer, else the default worker;--workeris refused, and before the panel runs one stderr line per member names it,panel <dimension>: <adapter>/<model> (from <source>)(issue #469). The personas are embedded (internal/flywheel/review_personas/<dimension>.md):correctness,security,tests,errors(error handling and resources),cross-os,contract(flags, event kinds, exit codes, config keys, backwards compatibility) anddocs. A member’s prompt isreview_prompt.mdplus its persona file, kept at.flywheel/reviews/<task>.<round>.<dimension>.prompt.md. Every finding in its answer must carry its dimension ascategory; one outside it refuses the answer (the same refuse-and-retry-once path). All members of one run share a round; their findings are<task>-r<round>-<dimension>-<n>. A member’sreviewedevent keeps personareviewer(so status derivation and every agent-review check are unchanged) and records its dimension incategory;Validaterefuses areviewedcategory that is not a persona, and accepts a personareviewer:<dimension>only for a known one. A member’s round closes only its own dimension’s findings; a general round closes all. Rounds are counted with a panel run as one round until a dimension repeats. With--fixeach loop round is a whole panel. The command prints the findings (or, with--fix, the open blocking ones), then the verdict matrix, and exits 0 when every dimension ispass, else 1 (6 on a refusal, 2 on usage). - A crashed member (issue #469): a member whose run fails (the reviewer exits non-zero, its stream
breaks, or its answer is refused twice) runs once more; failing again, the panel appends one
reviewedevent with verdictcrashed, personareviewer, the dimension incategory, the reviewed tree, the cause (the stream’s error result, else the stderr tail; about 300 bytes) innote, and thetokensandcostits two failed runs spent (issue #459), and goes on with the next member.Validateacceptscrashedonly with acategory. It names no adapter, so it closes no finding, counts as no round and changes no status. Any other error (persona, config, ledger, rule) still stops the panel. With--fix, a round with a crash still corrects the open blocking findings inside owns; with none open it never passes: it reviews again while rounds remain, then ends with verdictincompletenaming the crashed dimensions. - The verdict matrix (
VerdictMatrix): per panel dimension, the verdict of the latest agent or crashedreviewedevent of that dimension on the tree —pass,correctorcrashed(neverpass) — elsemissing; acorrectwhose blocking findings are all dismissed counts aspass. The thread groups a panel round as one## Round <n> — review panelsection with one line per member and its findings under a### <dimension>heading, then## Panel verdict matrix — tree <sha7>. Whenreview.panelis configured, the floor shows each unit’s matrix after its run state aspanel <cells>, one cell per dimension in panel order on the unit’s measured tree (its latestowns_checkedorvalidatedreading):✓pass,✗correct (an open blocking finding),·not reviewed on that tree. The cells’ room comes out of MODEL, then SESSION; when they cannot give it, the cells are dropped and the task id is never cut.--jsoncarries them as a unit’spanel. - Numbers:
flywheel statsbreaks the review down per persona (review.by_persona: persona, levelunitorgroup, reviews, findings,by_severity, and how the findings were answered —fixedordisputedby the worker’s latestfinding_response,dismissedby a lead) and per level (review.by_level:unit,group, andreleasewhen a release audit ran — rounds, rounds not pass, findings, blocking; a release audit’s findings are its failed checks). A finding counts for a panel persona only when its id is<task>-r<n>-<dimension>-<i>; others aregeneral.flywheel review calibrate --panel [dims](bare: the configuredreview.panel) calibrates each persona over the same sampled PR states (same seed) and reports, per persona, the PR states it reviewed, hits, misses, extra findings and recall, then the panel, which hits a case when any persona hit it; the report’s**Total: ...**line is the panel’s. A persona whose review fails is skipped for that PR state alone. - Config:
review.panel— the members,[{"persona": ..., "worker"|"adapter"+"model": ...}];config get/set review.panelreads and writes a comma-separated persona list (a member keeps its reviewer). Unset, the panel iscorrectness, tests, errors, contract, docs;securityandcross-osare opt-in.review.required(defaultfalse) makes a complete panel a condition of every pass.review.panel_min_lines(default0, off;config setrefuses a negative or non-integer value) scopes a small unit’s configured panel to one reviewer (issue #459): seepanel_scoped. - Enforced: when the task has been reviewed by the panel, or
review.requiredis set,flywheel inspect --verdict passis refused (rulepanel, exit 6) unless every configured dimension ispasson the tree being inspected, naming each<dimension>=missing|correctand the command; andflywheel verifyfails rule P1 for such a pass (§2). “Configured” meanspanelForbelow.
panel_scoped
- Written by: the CLI only, via
flywheel review <task> --agent --panelwith the configured panel (no explicit member list) andreview.panel_min_linesN > 0 (issue #459). Before any member runs, it counts the unit’s changed lines on the tree it is about to review: added plus deleted lines ofgit diff --numstat <base> -- <paths>(the unit’s dispatch base and changed paths, as the review diff uses; a binary file counts 0) plus the lines of each new untracked file among those paths. Below N, only one member runs, the configuredcorrectnessmember (else the first), and this event is appended first; the command prints<task> review panel: <n> changed lines < review.panel_min_lines <N>; one reviewer: <persona>and a one-line verdict matrix. At or above N, or with N = 0, the full panel runs and nothing is recorded. - Carries:
task,tree(the tree the member reviews),panel(the one persona),session, andnote(<n> changed lines < review.panel_min_lines <N>). Refused withouttask,treeor a non-emptypanel. panelFor(events, task, tree, configured): thepanelof the latestpanel_scopedevent of the task on exactlytree, else the configured dimensions. Inspect’s rulepanel, verify’s P1 (over the events before each pass, on its tree),flywheel recoverand the floor’spanelcells all use it, so they agree from the ledger alone and never re-measure. A tree that changed after a scoped review (a fix round) has no record and needs the full panel, unless the next panel run measures it small and scopes it again.
group_reviewed
- Written by: the CLI only, via
flywheel review --group <goal|tasks:a,b> --agent --session S [--base REF] [--worker NAME](issue #420): a group of units validated together. A group is a goal id (every task planned with thatgoal_id) or an explicittasks:<a>,<b>,...; its own records carry taskgroup:<id>(the goal id, or the list with:and,turned into-:tasks:a,bisgroup:tasks-a-b), whichValidateaccepts wherever a task id is required. - The integration tree: a detached
git worktree addof the base (defaultmain) in a temp dir, then each member’s work merged in member order withgit merge --no-ff --no-editas flywheel’s identity — its task branchfw/<task>when it exists, else thecommitof its latestfinishedevent. A member with neither is reported as missing and skipped; a merge that conflicts is aborted, its paths recorded, and the next member continues. The tree is removed afterwards. - Group gates: each
review.group_gatescommand runs in the integration tree like a gate (bash -c, captured output) and is recorded as avalidatedevent with taskgroup:<id>and gateg<n>. - The integration reviewer: the embedded
integrationpersona (never a panel dimension) reviews the combined diff base..tree, with a prompt section listing each member and its effective owns, the missing members, the conflicts and the gate readings; its prompt and stream are.flywheel/reviews/group-<id>.<round>.prompt.mdand.jsonl. It reports only defects that involve more than one unit’s change or the merge itself (merge seams, duplicated logic, conflicting assumptions, contract drift between units); every finding must carry categoryintegration(the same refuse-and-retry-once path as the review agent). - Routing: each finding becomes a
review_finding(personareviewer:integration, categoryintegration,reasonthe group task, idgroup:<id>-r<round>-<n>) on the first member whose effective owns contain its file, else ongroup:<id>. Each conflicting path becomes ablockeron the member whose merge conflicted. - Carries:
task(group:<id>),verdict(correctwhen any blocker or major finding or any failed group gate, elsepass),tree(the integration tree),commit(its HEAD),session,model,adapter, personareviewer:integration,note(members ...; missing ...; conflicts ...; gates g1=<rc> ...; <n> finding(s)). The gate readings, the findings and this event are one append. A session that is a worker session of a member is refused (T4). - Effect: no status change. An integration finding is closed only by a later review of the same
group (which re-reports a defect still present under a new id) or by a lead’s dismissal
(
flywheel review <task> --dismiss <id>); a unit’s own review never closes one. The thread is.flywheel/reviews/group-<id>.md(same marker rule as a task’s thread), and each member a finding was routed to has its own thread refreshed. - Config:
review.group_gates(default none);config get/set review.group_gatesreads and writes the commands separated by;;(set also splits on newlines; an empty value clears them). The integration reviewer may run the members’ gate commands and the group gates, as a unit’s reviewer runs its unit’s.review.allowed_tools(default none) adds claude--allowedToolspatterns to every reviewer’s read-only policy;config get/set review.allowed_toolsuses the same separators, and an empty entry is refused. - Enforced:
flywheel landrefuses (rulegroup, below). - State: a group task is not a unit.
Deriveleavesgroup:<id>out oftasks(andcounts) and lists it undergroupsin.flywheel/state.json:id,task,members(from the latestgroup_reviewednote),verdict(the latest),rounds,open(its open blocking integration findings, on its members or on the group task) andupdated_at;groupsis omitted when there is none. The floor (flywheel factory, text and--json) shows agroups (<n>)section, one row per group — id, verdict,open <n>, members — and an andon entrygroup:<id>group-open (<n>)while one is open. - Exit: 0 on
pass, 1 oncorrector an error, 6 on a refusal, 2 on usage.
blocked
- Written by: the controller (
flywheel controller), when a task’sneeds:target is scrapped. - Carries:
task,reason(names the needs target). - Effect:
Derivesets statusblocked.
lost
- Written by:
flywheel controller,flywheel run(before its owns and exclusive collision checks) andflywheel next(before it recommends), for the current attempt of a dispatched or running task that is abandoned (issue #402): its lease has expired, or no lease exists and its run file.flywheel/runs/<task>.<attempt>.jsonl(with no run file, itsdispatchedevent) is older thanlimits.lost_after(a Go duration, default24h). A live lease is never lost, and a lost attempt is not in flight, so it never blocks a dispatch. Until thenflywheel runrefuses any dispatch of a task whose attempt is stilldispatchedorrunning, fresh,--delta,--incrementor--resumealike (exit 6, rulein-flight, issue #522), before recording anything. - Carries:
task,attempt,reason(lease-expiredoridle),note(the evidence:lease expired at <time>,no live lease; run file idle since <time>, orno live lease; no run file; dispatched at <time>). - Effect: a stale-kind event, ignored unless its
attemptmatches the task’s current one; otherwiseDerivesets statuslost.
withdrawn
- Written by: the lead, via
flywheel log --task <id> --kind withdrawn --note "<why>"(issue #479), to take a plan back — typically one planned under an id another flywheel root already claimed.--noteis required (exit 2 without it).flywheel logrefuses it (exit 6, ruleW1) while the task’s current attempt isdispatchedorrunning; the same check holds for a--jsonline. - Carries:
task,note(why), optionallysession. - Effect:
Derivesets statuswithdrawn(same-instant rank afterblocked, beforelost). It is terminal likelandedandlost: stationscrap, floor stagewithdrawn, never offered byflywheel next, and it holds no owns or exclusive claims. A laterplannedevent revives the id. - Verify: rule
W1(section 2).
rebased
- Written by: the CLI only:
flywheel rebase <task> [--onto REF](issue #414), orflywheel log --kind rebasedfor a hand rebase (below). The rebase command writes it aftergit rebase --onto <onto> <base> fw/<task>succeeded in the unit’s task worktree (.flywheel/worktrees/<task>; anywhere else the command refuses).ontodefaults to the integration branch (below); a configured one that does not resolve is an error namingintegration.branch. On a conflict the rebase is aborted, the branch is left as it was, the conflicting paths are listed (exit 1) and nothing is recorded. A rebase done by hand is recorded withflywheel log --task <t> --kind rebased --base <ref> --note "<old base> onto <ref>"(issue #498):--baseis resolved to a full commit in--dir, and a missing--task,--baseor--noteis a usage error (exit 2). When the branch was rebased by hand onto a newer integration commit and that went unrecorded (issue #672),<base>is the branch’s real fork point (`git merge-base HEAD`, used when the recorded base is its ancestor), not the stale recorded base, so the integration branch's own commits are never replayed; `flywheel validate` reports the same drift as `base drift: recorded , branch forks from ; record it: flywheel log --task --kind rebased --base ` (JSON `base_drift`), a reading that never changes the owns check. After the `rebased` event the command re-runs `worktree.setup` when configured, in the task worktree, and appends a `worktree_setup` event (no attempt); a setup that fails exits 1 naming the command, its rc and its output tail, with the rebase kept. - Carries:
task, optionallyattempt,base(the new base:git rev-parse <onto>),note(was <old base>, onto <onto>, plus, branch forked from <fork> (rebased outside flywheel)when the fork point was used).Validaterequires the base and the note. - Effect: no status change. The unit’s base (
dispatchBase) becomes the latestrebasedevent’sbaseinstead of the firstdispatchedevent’s, so the owns check and the review ranges measure from there. - A unit is stacked when its base is not an ancestor of the integration branch and a commit on
it after their merge-base carries a
Flywheel-Task: <T>trailer for another unitTwhose branchfw/<T>contains the base (or, withfw/<T>gone,Tislanded):Tlanded as a squash and the unit still carriesT’s pre-squash commits.flywheel validatethen records the warningbase <sha7> (unit <T>) was squash-merged as <sha7>; run: flywheel rebase <task>as theowns_checkednote (the reading still runs),flywheel landrefuses (rulestacked, below), and the floor shows the done unit’s run state asstackedon the andon. - The integration branch (issue #456) is
.flywheel/config.json"integration": {"branch": "<name>"}when set (validated: non-empty, no whitespace, not starting with-; set withflywheel config set integration.branch <name>, an empty value clears it, issue #529), elsemainwhenrefs/heads/mainexists, elsemaster.flywheel rebase(default--onto), stacked detection (validate,land’sstackedrefusal,recover, the floor),flywheel review --group(default--base,mainwhen none),flywheel review calibrate(default--mainorigin/<branch>,origin/mainwhen none) andflywheel init --ci(the audit workflow’s pushbranches) read it;flywheel doctorprintsintegration branch: <b> (integration.branch)or(detected)on stderr and warns when a configured branch does not resolve. - The ship signature is
.flywheel/config.json"ship": {"signature": false}to turn it off (absent ortruemeans on;flywheel ship --no-signatureturns it off for one run). When on,flywheel shipaddsShipped-by: flywheel <version> (unit <task>, attempt <attempt>, <passed>/<total> gates)to its ship commit (besideFlywheel-Task:) and to the squash-merge message’s final trailer paragraph, and aShipped by [flywheel](...) <version> · unit ... · <passed>/<total> gates · <n> correction(s)footer to the PR body; each once, all built from the ledger and the binary’s version. - The ship required checks are
.flywheel/config.json"ship": {"required_checks": ["test", "lint"]}(issue #640): the check namesflywheel ship’scistep waits for on the PR’s head commit before it merges; it may name a commit status. Absent or empty means the names of the check runs (never commit statuses) reported on the head commits of every one of the last 3 pull requests merged into the integration branch (none merged: no expected checks);--ignore-check NAMEremoves a name from either set. A check named inrequired_checksmust concludeSUCCESS:SKIPPEDorNEUTRALon it failsciat once withrequired-check-skipped(issue #653), while an inferred expected check that skipped still counts as present. - The worker policy (issue #692) is
.flywheel/config.json"worker_policy": {"deny": ["pio run -t upload", "node scripts/flash.mjs"]}: command prefixes, as a worker would type them, that no worker may run (a device upload, a deploy, a publish). Each entry is added to the adapter’s built-in deny list, never replacing it.LoadConfigrejects an empty or whitespace-only entry and one containing*,(,)or:(pattern syntax flywheel adds itself), namingworker_policy.deny[<i>]. Enforcement per adapter: claude appendsBash(<prefix>:*)to the worker’s resolved--disallowedTools(the git-write defaults, or its owndisallowed_tools), deduplicated, and a denial is attributed to that pattern like a git one (thestopsignal’sBash: <pattern> (<segment>), issue #497); opencode leaves.flywheel/opencode-worker.jsonas the user wrote it andflywheel runchecks itspermission.bashholds"<prefix>*": "deny"for every entry (the embedded default when the file is missing), refusing the dispatch with the missing patterns, the file and “add them after the"*": "allow"line”; codex and pi cannot enforce a command deny list, soflywheel runrefuses withworker_policy.deny is set but the <adapter> adapter cannot enforce a command deny list; use a claude or opencode worker; sim ignores it. Both refusals come before any event (nodispatched, nodispatch_refused). Separately,flywheel config validateexits 1, while loading still succeeds, when a worker’sdisallowed_tools(which replaces the defaults) omits any ofBash(git commit:*),Bash(git push:*)orBash(git reset:*).
recovered
- Written by: the CLI only, via
flywheel recover --apply(issue #422), when it applied at least one safe action, and viaflywheel supervise --resume-limited(issue #472) or the controller’s auto-resume (issue #528) for each auto-resume. - Carries: no
task;note(the actions applied,;-separated, e.g.mark-lost T r1 (lease-expired) checkpoint 1a2b3c4,re-validate T,rebase T onto 5d6e7f8; or oneauto-resume <task> <attempt> after rate limit (<n>/<cap>)),paths(the tasks they touched, sorted),sessionon an auto-resume (--session, default$FLYWHEEL_SESSION, elsesupervise; the controller’s,$FLYWHEEL_SESSIONelsecontroller).Validaterequires the note and refuses a task. - Effect: no status change; the applied actions record their own events (
lost,validated,owns_checked,rebased; a resumed run itsdispatchedandfinished). flywheel supervise --resume-limitedacts on the recover next actionresume-session(below) for a unit that is not dormant and whose latestfinishedis its current attempt’srate-limitedone (neverabandoned-job, never any other reason; await-resetunit, its model still paused, waits until the pause passes). Still undersupervise.lock, it appends theauto-resumeevent FIRST, then startsflywheel run <task> --resume --session <session>in the background, its output appended to.flywheel/runs/<task>.autoresume.log; a crash between the two never starts the unit twice. Anauto-resumeevent later in the log than the task’s latestfinishedmeans a resume was already started for that finish: a second pass starts nothing. The cap islimits.rate_limit_retriesauto-resumes since the task’s latestplannedevent: past it the pass reportsnot resumed: auto-resume cap N reachedand starts nothing. A failed start keeps its event (it counts toward the cap). Exit codes are unchanged.flywheel controllerruns the same pass (issue #528) after each tick’s reconcile actions whilecontroller.auto_resumeis on (the default;falseturns it off): same selection, cap and event-before-start ordering, undersupervise.lock(a tick waits at most 1s for it; a busy lock skips the pass that tick with a warning on stderr, the next tick retries), itssession$FLYWHEEL_SESSION, elsecontroller. Each unit it acted on prints one line after the tick summary:resumed <task> <attempt> (log .flywheel/runs/<task>.autoresume.log)ornot resumed <task>: <reason>. For every unit actually started it runscontroller.notify, when set, through the gates’ shell withFLYWHEEL_RESUMED="<task> <attempt> model=<model>"; a notify failure only warns.flywheel supervise --resume-limitedis the one-shot form.flywheel controller --health-every D(default5m;0disables) makes each tick end by appending onehealthevent when none is recorded or the latest is at leastDold: at most one per interval, idempotent within it.flywheel status --health [--stale-after D] [--json]prints the latest ashealth <age> ago: running N, stalled N, rate-limited N (paused: <model> until HH:MM), andon N, oldest <task> <age>, controller gen G, flywheel <version>, orhealth: none recorded;--jsonprints{ts, age, stale, health}(nullwhen none). A record older than--stale-after(default10m) printshealth STALE (<age>): the controller is not recording; run flywheel controllerand exits 1 (exit 0 otherwise). The recorder also writes the record’sstale_after:controller.health_stale(a Go duration, positive) when set, else twice--health-every(issue #552). When the latest record is older than itsstale_after, the factory floor (factory,watch, the TUI) shows one andon entryhealth STALE: the controller is not recording <age>; a record withoutstale_after(an older writer) never does.status --healthstill judges by--stale-after.limits.checkpoint_every(a Go duration, default10m;"0"off): an attempt running in its task worktree is checkpointed torefs/flywheel/checkpoints/<task>/<attempt>on that interval, only when its owned files changed.limits.shell_timeout(a Go duration, default60m, positive): the longest foreground command a claude worker’s Bash tool may run.flywheel runsets the worker’sBASH_MAX_TIMEOUT_MSto it (overriding an inherited value) andBASH_DEFAULT_TIMEOUT_MSwhen the environment has none, and the abandoned-job resume delta names it, issue #678.flywheel recover [--json] [--apply] [--all] [--dormant-after DUR] [--session ID](read-only without--apply) is where every lead session starts. It checks integrity: the log’s hash chain and every §2 rule over every task. A rule failure on a task that is notlandedfails integrity. A failure on alandedtask is history: it can no longer be acted on, so it is reported and counted (integrity.history; the text shows the count and the first five,--allevery one) and never fails integrity or the exit status. Then per task (asDerivesees it) it checks:- the task worktree (
.flywheel/worktrees/<task>, else the attempt’s recorded workdir). Its HEAD must contain the attempt’sfinished.commit(git merge-base --is-ancestor). A lead commit or merge on top is consistent; a laterrebasedevent explains a move; a git failure is unknown, never a mismatch. Its uncommitted paths are compared with the attempt’swrotelist. The dispatch baseline with unchanged content and the pathsworktree_setuplinked are excused; the rest areunexplained. - the lease (
live,dead,none). - the run file (
completewhen its last byte is a newline,torn,missing). - a stacked base, a paused model, and the task’s checkpoints.
A
landedtask is reported from the ledger alone (no worktree, lease, run-file or tree reads), and the tree hash is computed once per workdir, so recover stays fast on a large ledger (#628).A task not
landedwhose latest event is older than--dormant-after(default168h;0disables) is dormant (JSONdormant: true). Its next action is still computed, but the text shows dormant tasks as one summary line (count and ids) unless--all, and--applynever acts on one: it is listed asdormant. Landed tasks are likewise one summary line (N landed units) unless--all. Exit 0 when integrity passes and no task’s next action isinvestigate, else 1.Several lead sessions may share one ledger (issue #472).
--session ID(default$FLYWHEEL_SESSION) names yours. Each task carrieslead, theleadof its latestdispatchedevent that records one (a dispatch without a session, areview --fixround say, keeps it).integrity.failedstays flat.integrity.by_leadgroups it by that lead: your session’s group first (current: true), then the other recorded leads sorted, then""(unrecorded, or an item with no task). When at least one failed item’s lead is recorded, the text prints the failures underlead <id> (this session): <n>,lead <id>: <n>andlead unrecorded: <n>headers; otherwise it keeps the flat lines. With a session given, a task another lead dispatched ends its line with ` [lead]`. Export `FLYWHEEL_SESSION` once per lead session so `flywheel run` records it and `flywheel recover` groups by it. - the task worktree (
-
The next action per task, first match wins:
Condition Action Command landednonenot in flight, worktree HEAD is not the attempt’s commit investigatenot in flight, uncommitted paths no attempt wrote investigatedispatched/running, lease live nonedispatched/running, lost by the lostrules abovemark-lostflywheel recover --applydispatched/running, otherwise noneunclean finish or lost, model pausedwait-resetflywheel run <task> --resume(waits for the reset)finished suspended, the factory still suspendedresume-sessionflywheel resume --session <s>finished suspended, the factory thawedresume-sessionflywheel run <task> --resumefinished rate-limitedorabandoned-jobresume-sessionflywheel run <task> --resumelost, or any other unclean finishnone(dispatch or correct)finished stoporpassed, stackedrebaseflywheel rebase <task>passedlandflywheel land <task>open blocking findings outside the effective brief’s owns (issue #458) assign-owner(assign them to another unit, amend owns, or dismiss them; the reason names the ids and the thread)flywheel review <task> --dismiss <id> ...any other status than finishednoneno owns_checkedreading since the finishre-validateflywheel validate <task>the tree changed since that reading re-validateflywheel validate <task>readings complete and passing, review panel applies and is incomplete reviewflywheel review <task> --agent --panel ...readings complete and passing inspectflywheel inspect <task> --verdict pass ...otherwise (readings failed on the current tree) none(correct) --applyruns onlymark-lost(as the controller does, and it checkpoints the lost attempt’s changed owned files, since a killed process never reached itsfinishedevent),re-validate(a measurement) andrebasewhen the base is certainly squashed andgit rebasereports no conflict (a conflict is aborted and listed).resume-session,wait-reset,review,inspect,land,assign-ownerandinvestigateare never run; they are listed for the lead.- Checkpoints. An attempt that ends uncleanly (
error,rate-limited,stalled,silent,abandoned-job,length,suspended) after writing files has its changed owned paths snapshotted: a temporary index reads HEAD, adds those paths, writes a tree, andcommit-treemakescheckpoint <task> <attempt>on top of HEAD, kept atrefs/flywheel/checkpoints/<task>/<attempt>— never the branch, never the real index. Thefinishedevent carries the sha ascheckpoint; a checkpoint failure goes on its note and never fails the run.flywheel checkpoint list|diff|restore|dropmanages them;restorerefuses over uncommitted changes to the checkpoint’s paths unless--force.
landed
- Written by: the CLI only, via
flywheel land <task> --commit <sha>. - Carries:
task,commit,note. - Effect:
Derivesets statuslanded. Verify’s T5 (ruleT5) requires an earlierinspected passor a recordedexceptedevent for the task;LandTaskitself refuses live (exit 6, rule T5) unless the task’s derived status is alreadypassedor an exception is provided, refuses (exit 6, rule T5, issue #673) a--committhat is on neither any remote’s<integration.branch>nor the local one, or touches none of the unit’s files (exits 8 when the commit does not resolve or no integration branch does; see T5 below), and refuses (exit 6, rule T9) while the task has untriaged signals unless--allow-untriaged <reason>records why, refuses (exit 6, rulestacked, issue #414) a unit whose base landed as a squash (seerebasedabove; the fix isflywheel rebase <task>, and an exception landing overrides it), refuses (exit 6, rulegroup, issue #420) while an open blocking finding with categoryintegrationis on the task or, when it was planned under a goal, ongroup:<goal>(seegroup_reviewedabove; close it by a new group review or a lead’s dismissal), and refuses to re-land the same task under a different commit than it already recorded. The read, the checks and the append(s) run under.flywheel/dispatch.lock(the lockrunandamendedtake) and then.flywheel/feedback.lock(the lock learning writers take; always in that order), so two concurrent landings of one task can never both pass the already-landed check, and no learning can change the task’s signals mid-decision.
land_corrected
- Written by: the CLI only, via
flywheel land <task> --correct <sha> --reason TEXT --session S(issue #673): a landing recorded with the wrong commit is corrected by a new event, never by rewriting thelandedone (the log is append-only) and never by anote. - Carries:
task,commit(the corrected commit),tree(its landed tree, aslandedrecords),note(the reason),session(the lead).Validaterequires the task, the note, the session and a valid commit on every write path. - Effect: no status change (the status stays
landed). The task’s effective landed commit is the lastlandedorland_correctedcommit in log order (landedCommit); the earlier ones are superseded, andflywheel explainlists them. Re-landing the effective commit is a no-op; any other commit is still refused (T5).CorrectLandingtakes.flywheel/dispatch.lockand refuses (exit 6, rule T5) when the task has nolandedevent (nothing to correct; land it first), when the commit is malformed or already the effective one, or when the--commitchecks oflandedrefuse it (off the integration branch, touching none of the unit’s files; exit 8 when it does not resolve), and (exit 6, rule T4) when the session is a worker session for the task.--correctcannot be combined with--commit,--merge,--by-lead,--exception,--allow-untriaged,--ontoor--note, and needs--reasonand--session(exit 2). Verify’s T5 fails aland_correctedevent without an earlierlandedevent, reason or session; T4 fails one from a worker session.
excepted
- Written by: the CLI only, via
flywheel land <task> --commit <sha> --exception TEXT --session S. - Carries:
task,commit(the commit the evidence covers),session(the lead),note(the evidence),reason(the status it overrode).Validaterequires the note, the session and a valid commit on every write path. - Effect: no status change by itself. It is appended in the same single write as the
landedevent it permits (AppendEvents), so a failure never leaves an exception without its landing. Verify’s T5 accepts a landing on an exception only when the exception names the same commit and reports it as “landed on a recorded exception” (a deliberate, visible exception to T5, never a silent bypass). T4 fails anexceptedevent from a worker session (one that wrote the task’sstarted,finished,dispatched,reportorworker_planevent).
allow_untriaged
- Written by: the CLI only, via
flywheel land <task> --commit <sha> --allow-untriaged REASON. - Carries:
task,commit(the commit the landing covers),note(the reason),signals(the condition names the landing allows — the untriaged signal names at the time the landing was recorded).Validaterequires the task, the note and a valid commit on every write path. - Effect: no status change by itself. It is appended in the same single write as the
landedevent it permits (AppendEvents), so a failure never leaves anallow_untriagedwithout its landing. T9 enforces that a landing refusing to triage signals must be recorded with this event; the signals are not triaged by this event (they stay listed byflywheel feedbackuntil a learning names them), and the event is purely for auditability and transparency.
audited
- Written by:
flywheel audit <task> --session S, from a session that did not plan, build or inspect the unit. - Carries:
task,verdict(conforms/nonconformance),session,tree,note(the findings),persona(auditor). - Effect: no status change, and its verdict never replaces the unit’s QC verdict in derived state.
The
treeis the one the gates were re-run on: captured before the clean copy is made and re-checked before recording (a tree that changed mid-audit records nothing). A record check that cannot be established (INCONCLUSIVE) is a finding: an audit that cannot confirm does not pass. The auditor’s independence is checked again right before the event is appended. T7 gating is opt-in: see T7 below. In config,Validaterefuses astaffing.auditoron the same agent and model as another role unless its RoleConfigindependenceis"session"(issue #463): a single-model factory then relies on this fresh-session check; a shared session is refused either way, andindependenceon any other role, or any other value, is a config problem.
release_audited
- Written by:
flywheel audit --release <version> --session S(issue #420), after a release is published. - Floor level: carries no
task. - Carries:
session,version(the tag,v0.21.1),verdict(pass,failorinconclusive),checks(onename=statusstring per check, in order:tag,changelog,binary,commands,docs,calibration; statuspass,fail,skippedorinconclusive) andnote(the same list joined by,).Validaterequires the session, the version, the verdict and at least one check; no other kind may carryversionorchecks. - Effect: no status change. Every repository file is read at the tag, never the working tree. The
verdict is
failwhen any check failed, elseinconclusivewhen any could not be established (a download or a run of the binary failed, or the tag is missing only from this clone or origin could not be asked; a tag missing on origin too failstag), elsepass. It is recorded whatever the verdict; a missing session or a usage error records nothing.
health
- Written by:
flywheel controllerwith--health-everyabove 0 (issue #528), at most once per interval. - Floor level: carries no
task. - Carries:
health, the snapshot:running(tasks dispatched or running),stalledandrate_limited(units in that run state on the floor),finished(finished tasks less the rate-limited units, so none is counted twice; issue #552),andon(the floor’s andon count),stale_after(the age past which the record is stale, a Go duration such as10m0s:controller.health_stale, else twice--health-every; omitted by older writers; past it the floor shows ahealthSTALE andon entry),paused_models([{model, reset_at}], each model a rate limit pauses and its RFC 3339 reset),oldest_in_flight(the longest-dispatched in-flight task and its age,T3 42m; omitted when none),controller_generation(the controller lock’s generation) andversion(the flywheel version).Validaterequires the snapshot and no task; no other kind may carryhealth. - Effect: no status change. Read by
flywheel status --health.
suspended
- Written by:
flywheel suspend --session S [--reason TEXT] [--until TIME](issue #572). - Floor level: carries no
task. - Carries:
session(who froze the factory),note(the reason) anduntil(the thaw time, RFC 3339; omitted when the freeze lasts untilflywheel resume).Validaterequires the session and no task; no other kind may carryuntil, and it must parse as RFC 3339. - Effect: the factory is suspended while its latest
suspendedevent comes after its latestunsuspendedevent and itsuntil, when set, is still ahead; pastuntilit thaws with no event. While suspended every dispatch path refuses and appends nothing:flywheel runexits 6 with rulesuspended(the factory is suspended since <ts> by <session>: <reason>; flywheel resume --session <s> to thaw),flywheel nextturns eachDISPATCHinto aWAITnaming the suspension, and the controller’s andsupervise --resume-limited’s auto-resume starts nothing, reporting each unit not resumed with reasonsuspended.flywheel statusprints a firstSUSPENDED since …line (suspendedin--json) and the floor lists afactory suspendedandon entry first. A secondflywheel suspendwhile suspended is refused (exit 6, rulesuspended). stop(flywheel suspend --stop; onlysuspendedmay carry it): the suspension also stops every live worker. The command writes the sentinel.flywheel/suspend.stop(content: the event’s ts). A runningflywheel runstats it on every lease tick (lease.renew_interval) and, only when it exists, reads the ledger and confirms the suspension carriesstop; it then kills the worker the way the stall watchdog does and finishes the attempt with reasonsuspended, notestopped by suspend at <ts>: the finished event keeps the session, the written owned files are checkpointed, a worktree’s changes stay in the worktree (no attempt commit), and the run exits 6.flywheel recoveroffers such a unitresume-session(reasonstopped by a factory suspension) with the commandflywheel resume --session <s>while suspended and `flywheel run--resume` once thawed; the floor shows it `suspended`, not failed. - Automatic freeze (
reasontokens-exhausted; issue #572): the automatic mark is the event’sreasonfield, empty on every manual suspension. Withcontroller.auto_freezeon (the default;falseturns it off), a controller tick appends asuspendedevent with sessioncontroller(the tick’s session),stop,untilthe earliest reset andnotetokens exhausted: <models> paused until <HH:MM> UTCwhen at least one worker is configured, every configured worker’s model and fallback models is paused by a rate limit (thereset_at/limit_reset_atpause atlimits.rate_limit_pause_at) and the factory is not already suspended. The first tick past thatuntil(with a starter configured) thaws it: it appendsunsuspended(notetokens returned), removes.flywheel/suspend.stopand, for every task whose latest finished event issuspendedorrate-limitedwith no laterdispatchedor auto-resumerecoveredevent, appends arecoveredevent (auto-resume <task> <attempt> after tokens returned,pathsthe task) and then startsflywheel run <task> --resumethrough the auto-resume starter (asuspendedunit with the continue delta above). The recorded auto-resume keeps the tick’s auto-resume pass from starting the unit again. A tick that thaws does not freeze. A manual suspension is never thawed by the controller: only byflywheel resumeor its ownuntil, and then with no re-dispatch.
unsuspended
- Written by:
flywheel resume --session S [--note TEXT] [--no-redispatch](issue #572). - Floor level: carries no
task. - Carries:
session(who thawed the factory) andnote.Validaterequires the session and no task. - Effect: thaws a suspended factory; refused (exit 6, rule
suspended) when it is not suspended. The command removes.flywheel/suspend.stopand re-dispatches every task whose latest finished event has reasonsuspendedand no laterdispatchedevent: it writes the continue delta.flywheel/briefs/<task>.delta.txt(the brief’s owns, needs and gates, then “You were stopped by a factory suspension; …”) and startsflywheel run <task> --resume --dir <dir> --session <s>detached, assupervise --resume-limiteddoes (log.flywheel/runs/<task>.autoresume.log), printingresumed <task> <attempt> (log <path>).--no-redispatchonly thaws.
probed
- Written by:
flywheel doctor --record. - Carries:
model,reason(the doctor class: ok, credits, key limit, consent required, auth missing, error, local endpoint down, model not pulled),note(flywheel doctor, orflywheel doctor: <detail>when the probe is not ok: the start error, the error message, orexit N: <first stderr line>/no stop (...), cut to 200 characters; issue #637). - Effect: an
okprobe newer than the model’s latest provider error closes its breaker at once instead of waiting for the cooldown to expire (issue #46).
gate_probed
- Written by:
flywheel lint <brief> --probe --task <id>(issue #544), one event per gate in a single append, before dispatch;--probewithout--taskrecords nothing, and--taskwithout--probeis a usage error (exit 2). - Carries:
task,gate(the gate’s 1-based index as a string),command(the gate command text),rc,duration_ms,reason(the probe’s first output line, or the spawn error text) andcommit(the probed HEAD when the dir is a git repo).Validaterequirestask,gate,commandandrc. - Effect: informational; no status change. It may precede the task’s
plannedevent:Deriveskips it, so it never creates a task. When a gate fails,flywheel validatelooks up the task’s newestgate_probedevent with the samecommand(the text, not the index) and, if itsrcis non-zero, prints<task> gate <N>: note: this gate already failed on the base tree before dispatch (exit <rc>): <reason>;flywheel explainadds(also failed on the base tree before dispatch, exit <rc>)to the failed gate’s line. Validate’s exit code is unchanged.
amended
- Written by: the planner or lead, via
flywheel log --task <id> --kind amended --brief <path> [--session S --model M] --note <why>; a--json-ingestedamendedevent lands through the same check. - Carries:
task,brief,header(the parsed brief header as recorded when the amendment was appended, as forplanned),sessionandmodel(the planner’s identity, recorded as forplanned),note,persona(planner), andowns/needscopied from the brief header, as forplanned. The--goalflag is a usage error (exit 2) with--kind amended. - Effect:
Deriveupdates onlybrief/needs/ownson the task, never its status. Verify’s T1 treats adispatchedhash mismatch as explained when anamendedevent for the task falls between that dispatch and now. - Limit: an amendment cannot change the gate set an attempt has already been dispatched with — a
pass is measured against the attempt’s effective header, so the command refuses (exit 6) an
amendment that would change the effective gate set instead of recording one that changes
nothing. The comparison is against the effective set
AttemptBriefmerges, not the dispatched header alone: a correction whose delta declares nogate:lines inherits the base gates, so amending them does change what validation runs and is allowed; a correction’s delta never replaceslive-gate:lines, so an amendment touching onlylive-gate:takes effect on a correction attempt but is inert — and refused — on a fresh dispatched one. Change the gates of a dispatched attempt with a correction delta:flywheel run <task> --delta <file>. An amendment that widensowns:after a fresh dispatch takes effect: the attempt’s effective owns are the dispatched header’s plus every later amendment’s (issue #281). One that would narrowowns:orexclusive:cannot take effect — both are unioned — so it is refused (exit 6) like an inert gate change. Narrowing is judged by coverage, with the matching the owns check uses: replacingsrc/withsrc/main.gostops coveringsrc/other.go, so it is a narrowing. A JSON-ingested amendment without aheaderis stored with the header parsed from its brief, so it takes effect the same way. A legacy dispatch that recorded no header is measured against the latest amendment, where a narrowing does take effect, and is not refused. Fixing prose is allowed. The refusal and the append run under.flywheel/dispatch.lock, the same lock fileflywheel runholds across its own read-check-append, so an amendment and a dispatch serialise. - Acknowledging a lost delta:
flywheel log --task <id> --kind amended --attempt c<n> --note <why>(no--brief) records that correctionc<n>’s delta is not retained — for ledgers written before the per-attempt snapshot, where a later correction overwrote a shared delta file (issue #452).Validaterequires a correction attempt (c*) and a non-emptynote;flywheel log(the flag path and--jsonalike) also requires adispatchedevent for that attempt and refuses abrieforheader. It amends no brief: it never excuses a fresh attempt’s mismatch. T1 passes that one correction’s mismatched or unreadable delta only when the acknowledgement was recorded after its dispatch, with reasonacknowledged: delta for c<n> not retained (<note>); it never waives a correction silently, and never one with no acknowledgement.
Because planned, amended and dispatched events carry the parsed header, the log is
self-contained: a pass is measured against the header recorded in it, so a brief edited on disk
after the fact — even one re-recorded through flywheel log --kind amended with the same path —
no longer changes what any recorded pass is measured against. On a dispatched attempt, owns widen
with --kind amended; gates change only with flywheel run <task> --delta <file>, which is what
flywheel run’s brief-drift message names (issue #387). An amended event whose brief matches the
file on disk records the change, as a later planned one does, so later dispatches neither warn
brief-drift nor refuse under --strict-brief (issue #617). An event without a header falls
back to reading the file at its brief path, so ledgers written before this field existed keep
working exactly as before.
validated
- Written by: the CLI only, via
flywheel validate <task>, once per declaredgate:line, orflywheel attest(an external reading, below). - Carries:
task,attempt,gate(1-based index, as a string),command,tree(git tree hash),commit(the repository HEAD at the moment this reading was taken, not at the start of the pass — each gate resolves it independently, so two readings in one pass may carry different commits and that is correct, not a bug; empty when the workdir is not a git repository or HEAD cannot be read, so a consumer must treatcommitas optional and never assume a non-empty value, issue #240),workdir(the git working tree the reading was taken in, recorded in canonical absolute form — symlinks resolved, DOS 8.3 short names expanded — and only when it differs from the flywheel root: an external--workdirclone, so a verifier can resolve the tree object in the right repository, issue #244; omitted on same-dir readings, including aliases of the root),rc,duration_ms,sha256(of the gate’s combined output),path(.flywheel/evidence/<task>/<attempt>/gate-<n>.log),persona(always"supervisor", hardcoded — see §4),reason/note(host-blockedwhen Windows Smart App Control blocked the freshly built binary twice in a row). - Effect: no status change.
Validaterequiresgateandtreeto be non-empty. Ahost-blockedreading never counts as passing for T3, matchingValidateTask’s ownGatesOK=falsefor it. - A gate that fails for a reason attributable entirely to a changed path outside the unit’s own
owns:— another unit’s half-written file in the same tree, not this unit’s own work — is recorded withreasoninconclusiveand anoteofblocked by <paths>(issue #162). T3 still requires a passing reading for every declared gate: aninconclusivereading is not a pass, andflywheel validatestill exits 5 for it, exactly like an ordinary failure. - A quiet gate (issue #411) is a
gate[quiet]:orlive-gate[quiet]:header line: a gate that host contention distorts (device timing, hardware in the loop). The parsed header keeps its command among the gates or live gates as usual and lists its 1-based index inQuietGates/QuietLiveGates. Before it runs,flywheel validatetakes the exclusive.flywheel/locks/quiet.lock(recording task, gate, pid, host andstarted_at) and polls until no other task has a live lease on this host and no other process holds a gate marker; the validating task’s own lease is ignored. Every ordinary gate holds a shared marker (.flywheel/locks/gates/<pid>-<n>) while it runs, and first waits while another process holdsquiet.lock; after the budget it runs anyway with the noteran during a quiet gate (<task> gate <n>). Both waits are bounded bylimits.quiet_wait(a Go duration, default30m). A lock or marker whose pid is dead on this host is stale and ignored. When the host never goes idle, the quiet gate does not run: it is recorded withreasoninconclusive, norcand the notehost busy: <tasks>— unmeasured, never a failure, and not a pass for T3. Whilequiet.lockis held by a live process,flywheel runrefuses every dispatch (exit 6, rulequiet:a quiet gate (<task> gate <n>) is running on this host; dispatch after it ends). - An external reading (
source"external", issue #367) is one flywheel did not measure:flywheel attest <task> --commit <sha> --evidence <url> --session <lead>records that a named run elsewhere (CI on the unit’s PR) passed every gate on a commit. It writes onevalidatedpergate:andlive-gate:(rc 0) and one cleanowns_checked, in oneAppendEventscall, on the commit’s tree, each carryingsource,evidence(the run’s URL or reference),commitandsession;Validaterefuses an external reading missing any of the three. Only the lead writes them, never a worker:attestrefuses a worker session of the task (T4, and verify’s T4 fails one), a task with no dispatched attempt (T5), and a commit that changed a path outside the unit’sowns:against its first parent (T3). An external reading counts for T3 exactly like a measured one; verify fails one missing its evidence, session or commit, and names the evidence of a pass it relied on (attested: <evidence>on the T3 line).
owns_checked
- Written by: the CLI only, via
flywheel validate <task>, once per pass, orflywheel attest(an external reading, seevalidatedabove). - Carries:
task,attempt,tree,commit(the repository HEAD at the moment the owns check ran, resolved independently of the gates — each reading carries the HEAD at the time it was taken, so it may differ from the gates’ commits and that is correct, not a bug; empty when the workdir is not a git repository or HEAD cannot be read, so a consumer must treatcommitas optional and never assume a non-empty value, issue #240),workdir(as onvalidated— where the reading was taken, canonical absolute form, recorded only when it differs from the flywheel root, issue #244),outside(changed paths not covered byowns:),churn(optional map from a bareoutsidepath to"line endings only"or"whitespace only"when its bytes differ from the dispatch base only that way — a label for the lead to restore it byte-for-byte, never an excuse: the path stays inoutside, issue #647),baselined(changed paths excused because they were already dirty at dispatch and are byte-identical now),attributed(changed paths blamed on another in-flight task instead — see below),persona("supervisor"). - Effect: no status change. T3 requires an
owns_checkedwith an emptyoutsideon the same tree. - The changed paths are the unit’s changes since the attempt was dispatched — uncommitted and untracked
files plus files touched by the branch’s own commits since the
basecommit of the unit’s first dispatched event, or of its latestrebasedevent (issue #414) (a correction attempt’s check still counts what an earlier attempt committed) (files merged in from another branch are not the unit’s) — so committing a stray edit does not hide it (issue #332). Commits reachable from the integration branch (origin/<integration.branch>, else origin/main or origin/master, else the local branch) are never the unit’s, so a branch that fast-forwarded to main is not charged with main’s files (issue #581). - A changed path outside
owns:and not baselined is attributed rather than outside when some other task’s briefowns:it (ownsContains, the matchingflywheel validatealready uses) and that task is currently in flight (Derivestatusdispatched,running, orfinished— neverlanded,passed, orrejected): a neighbour’s own work in progress on a shared checkout, not this task’s stray file (issue #117). Attribution never excuses a path this task’s ownowns:already covers — such a path was never outside to begin with — and never hides a path no in-flight task owns: that path is stilloutside, and T3 still fails it.flywheel validateprints attributed paths as<task> owns: attributed <path> -> <task>[, ...]before the outside line. - In a sibling worktree (another git worktree of the same repository, compared against the
dispatch-time snapshot), a changed path is attributed to a task of THAT worktree’s own ledger
whose brief owns it, which was dispatched, and which has not landed (any status except
landed) — its own ledger is the authority for its own worktree, and a unit that passed inspection still owns the uncommitted edits made in its worktree afterwards (issue #278). A task that was only planned never ran and attributes nothing; a path no such task owns is stilloutside.
inspected
- Written by: the CLI only, via
flywheel inspect <task> --verdict ... --session .... - Carries:
task,verdict(pass,rework,scrap, orescalate),tree,session,note,commit(with--commit <sha>: the tree inspected is that commit’s, not the working tree’s — how an attested, already-merged commit is inspected, issue #367),workdir(the git working tree inspected, canonical absolute form, recorded only when it differs from the flywheel root, issue #244),persona(always"inspector", hardcoded byInspectTask— see §4 for the only way a"lead"ever appears there). - Effect:
Derivemapspass→passed,rework→needs-correction,scrap→rejected,escalate→blocked.InspectTaskenforces T4, and for apassverdict T3 too, before the event is even appended — a refused inspection never reaches the log at all.
staffed
- Written by:
flywheel staff --role <role> --session <session> [--model M]. - Carries:
session(required — floor-level kinds are the ones allowed an emptytask),persona(defaults to"lead"when not given). - Effect: task-less;
Deriveskips it outright (if e.Task == "" { continue }). It only feedsflywheel factory’s floor view.
session_start
- Written by:
flywheel log --kind session_start --session <id>, invoked by the Claude Code hook or the OpenCode plugin (flywheel-session.mjs) thatflywheel init --hooksinstalls, on a session’s first turn (issue #157). The same--hooksalso installs a Claude CodeStophook that runsflywheel gateand blocks ending the session while units are finished but not inspected or signals are untriaged (issue #56). - Carries:
session(required, likestaffed) and notask. - Effect: floor-level;
Deriveskips it outright, the same way it skipsstaffed. `flywheel trace` is its only reader.
flywheel init --git-hooks adds the git layer (issue #56): a commit-msg hook refuses a commit
without a Flywheel-Task: <id> trailer (merges, reverts and fixup/squash commits are exempt), and a
pre-push hook refuses a push while a unit named in the pushed commits fails flywheel verify, or
flywheel verify --log finds the event log’s hash chain broken.
flywheel init --ci adds the CI layer: a flywheel-audit job running
flywheel verify --all --log on every pull request. Made a required status check in the branch
ruleset, it cannot be bypassed locally; it needs the event log committed, and an inconclusive check
(exit 8) only warns.
session_command
- Written by: the same hook or plugin, once for every
flywheel-prefixed command the session runs. - Carries:
sessionandnote(the command line) — both required;Validaterejects the event if either is empty. - Effect: floor-level like
session_start.flywheel trace <session>prints itsnotein the trace line’s detail column.
session_end
- Written by: the same hook or plugin, when the session ends.
- Carries:
session(required) and notask. - Effect: floor-level like
session_start.
learning
- Written by:
flywheel feedback add --task <id> --severity P0|P1|P2 --title T --observed O --evidence E --ask A [--signals a,b] [--scope flywheel|project], or aflywheel log --jsonbatch. - Carries:
task,severity,title,observed,evidence,ask(all required),signals, andscope(issue #409):flywheel— the default, and what an event withoutscopemeans — is feedback about flywheel;projectis the project’s own learning (its product bugs, say).Validaterefuses any other scope, and a scope on any other kind. - Effect: numbered L-01, L-02, … in log order whatever the scope; the generated
.flywheel/learnings.mdrenders a## Feedback for flywheeland a## Project learningssection.flywheel feedback exportandsubmitcarry only undismissed flywheel-scoped learnings and say how many project ones stayed local.
note
- Written by: anyone keeping a journal, via
flywheel log --kind note [--task T] --note "<text>" [--session S](issue #409).--noteis required. - Carries:
note(required), and optionallytaskandsession. - Effect: none — a journal line (
action: dispatched…,result: … merged) is never a learning, so it never reaches learnings.md or an upstream report.flywheel explainshows it asnote: <text>.
lead_edit
- Written by: the lead, via
flywheel claim-edit --paths <p1,p2> --session <session> [--note ...], to declare an edit it made itself after a unit’s dispatch (issue #228). - Carries:
session(the declaring session, required),owns(the claimed repo-relative paths, reusing the field that means “these paths belong to this declaration”),baseline(path -> sha256 of the content the claim declared, the same map field a dispatched event uses;"deleted"marks a path that was absent at claim time),note, and notask: the claim is repository-wide, not per task. Only literal paths may be claimed:claim-editrefuses a pattern (*,?,[) or a trailing/directory prefix with exit 2, because a pattern cannot be bound to one content hash and would exempt a whole tree. - Effect: no status change —
Deriveskips it like every other floor-level event. The owns check (attributeOutsideingauges.go) attributes a changed path a claim covers as `“-> lead "` instead of `outside`, so the reading stays clean; a path the claim does not cover is still `outside`. It is a **declaration, not an exemption**: the path still appears in the ledger, attributed to a named session, and the claim expires the moment the path's content no longer hashes to the recorded value (or the path reappears, for a deletion marker) — the lead declared that edit, not the file forever (issue #258). - Three guards a consumer can rely on. A
lead_editnever covers a path when (1) the declaringsessionis a worker session of the task being validated — one that wrote that task’sstarted,finished,dispatched,report, orworker_planevent, the same worker-event set T4 uses — (2) the claim’stsis not strictly before the reading being computed: a claim never retroactively blesses a stray an earlier validation already reported, or (3) the path’s current content does not hash to the recordedbaselinevalue: an unbound claim (no entry for the path) never excuses anything. The owns check re-reads the event log immediately before attributing, so a claim appended while the gates ran still qualifies for that reading. - With
--worktree <dir>the claim names another session’s edit in a sibling worktree: the paths are hashed relative to that worktree and the event carries it asworkdir, so it excuses"<worktree>: <path>"(attributed"<worktree>: <path> -> lead <session>") under the same guards, and never the same relative path in the unit’s own tree (issue #362). - Sibling worktrees (issue #339): a path changed in another worktree recorded at the unit’s
dispatch, owned by no in-flight unit’s brief there, is attributed `“
: -> lead "` when that worktree's own ledger has a `lead_edit` claim covering it under the same three guards (the worker-session guard against each of that worktree's in-flight units), and only while that worktree still has a dispatched, unlanded unit. A sibling that is another unit's `run --worktree` worktree, ` /.flywheel/worktrees/ `, is read against the MAIN ledger instead: while ` ` is dispatched and unlanded there, every changed path in it is attributed `" : -> "` (issue #386).
goal
- Written by:
flywheel goal add/flywheel goal set. - Carries:
goal(aGoalSpec:id,title,acceptance,requiredtasks,status— one ofactive,met,failed,abandoned, checked byValidate). - Effect: task-less, like
staffed.
2. Transitions the code enforces
flywheel verify runs eight rules against every task it is asked about — VerifyTasks calls
ruleT1, ruleT3, ruleT4, ruleT5, ruleT8, ruleR1, ruleW1, ruleP1 in that order — and the same
rules are enforced live, before the record is written, inside InspectTask (T3, T4, T8, R1 as
the refusal rule review, and P1 as the refusal rule panel) and LandTask (T5).
ValidateTask produces the readings T3 needs but enforces nothing itself; it can fail its own
gates (exit 5) without touching the log’s legality.
- T1 — dispatch matches its brief. A fresh (
r*)dispatchedevent’ssha256must match the brief named by the task’s latestplannedevent, unless anamendedevent for the task landed in between — then the mismatch is explained, not flagged. A correction (c*)dispatchedevent hashes its own delta file instead (the path in its ownbrieffield, the per-attempt snapshot); no brief amendment ever excuses a tampered or missing delta. The one exception is explicit: anamendedevent naming thatc*attempt with a note, recorded after its dispatch, acknowledges a delta lost before the snapshot existed, and T1 passes it with the reasonacknowledged: delta for c<n> not retained (<note>).verifyreports this per-task as ruleT1. - T3 — a pass needs current readings, not old ones.
inspected pass(or a live--verdict passinspection) needs, for the same git tree hash the unit actually built: a passingvalidatedevent from the supervisor for every gate the current attempt’s prompt declares, and a clean (outside-empty)owns_checked— both recorded after the latestfinishedevent that precedes the check. On a shared working tree the hash can move betweenvalidateandinspectbecause other units keep writing, so the rule is relaxed (issue #218): when no reading exists on the current tree, the reading may be taken on another treeTwhose difference from the current tree lies entirely outside the unit’sowns:— every one of the task’s readings must come from that sameT, and the recordedinspectedevent’s note then namesT(; reading from tree <T> (diff outside owns)). This is sound because every file the unit owns is byte-identical between the measured tree and the inspected one, so the unit’s own work was measured; it costs a little because a gate broader than the owned files could be broken by a neighbour’s later change, and T3 accepts that risk deliberately — the alternative is that correct work cannot land at all. That is not a licence to put whole-workspace gates on narrow units. “The current attempt’s prompt” isAttemptBrief’s result (issue #133): the base brief plus, when the current attempt dispatched a different file (a correction delta), that file’sgate:lines replacing the base’s and itsowns:unioned with the base’s — anamendedevent replaces which brief counts as the base outright.owns:entries are matched as a literal path, adir/prefix, or apath.Matchshell pattern, all three checked the same way (issue #135,ownsContains). An entry starting with!is negated (issue #388) and takes the same three forms: a path is owned when some positive entry matches it and no negated entry does, soapps/inc/**, !apps/inc/wake.hownsapps/inc/a.hbut notapps/inc/wake.h, and a negation with no positive entry owns nothing. flywheel’s own bookkeeping stays owned. The dispatch owns collision check applies the same rule, so that header does not collide with an in-flight task owningapps/inc/wake.h; an amendment adding a negation that removes a covered path is a narrowing; andflywheel lintnever checks a negated entry for existence, but warns when no positive entry covers it.owns: none(orowns: -, any case) declares a unit that owns no paths (issue #693), such as a read-only investigation: the entry is dropped rather than kept as a path namednone, so the unit never collides at dispatch, and any changed file outside flywheel’s own bookkeeping fails its owns check.flywheel lintcounts it as a present owns line and reportsnonecombined with real paths as a problem. Each inspection uses its own window, so a later correction attempt never invalidates an earlier legitimate pass. A pass measured in an external--workdir— a separate clone, not a worktree of the verifying repository — is verifiable from its own repo:flywheel verify --workdir <path>resolves tree objects there, and without the flag aworkdirrecorded on the task’s reading events is used when that path still exists (issue #244). When the pass’s tree cannot be resolved in any repository the verifier can see, the strict reading check still runs on the ledger’s own evidence (it needs no git): a complete reading passes T3, and an incomplete one is inconclusive (exit 8) rather than a violation — a verifier that cannot see the tree must not claim a violation it has not established. - T4 — no self-inspection. An
inspected,exceptedorland_correctedevent’ssessionmust never be a session that wrote that task’sstarted,finished,dispatched,report, orworker_planevent.InspectTaskchecks this before T3, so a worker-session inspection is refused as T4 even when its readings are also missing. - T5 — no landing without a pass. A
landedevent needs an earlierinspected passor a recordedexceptedevent for the same task.LandTaskadditionally refuses to land a task whose derived status is notpassed(unless an exception is provided), and refuses a secondlandedevent for the same task under a different commit than the effective one (the lastlandedorland_correctedcommit; the same commit is a silent no-op, exit 0). A wrong landing is corrected byflywheel land <task> --correct <sha> --reason TEXT --session S(seeland_corrected); aland_correctedevent needs an earlierlandedevent, a reason and a session. Land also verifies the--commitit records (issue #673): the commit must resolve, be an ancestor of an integration ref (any remote’s<integration.branch>, e.g.origin/mainorupstream/main, or the local branch; unconfigured, any remote’s or the localmainormaster), and change at least one of the unit’s paths (the diff from the last dispatch’s base to the last passing inspection’s tree, else the planned owns;.flywheel/andflywheel.mddo not count). A commit off the integration branch or touching none of the unit’s paths is refused (exit 6, rule T5, naming the commit’s subject); a commit that does not resolve, or no integration ref that resolves, exits 8 (inconclusive: rungit fetch). Outside a git repository, and when the ledger names no paths, the check that cannot run is skipped.flywheel land --mergeis exempt: it makes the commit from the unit’s own branch.flywheel shipfetches the integration branch before landing. - T8 — personas write only their own kinds.
validatedandowns_checkedmust carrypersona "supervisor";inspectedmust carry"inspector"or"lead". No other kind is persona-checked by this rule (§4 has the full picture, including what is and is not mechanically enforced). - R1 — no inspected pass while a blocking review finding was open (issue #389). An
inspectedpass fails whenOpenFindingsover the events BEFORE it held ablockerormajorfinding for the task; a finding raised after the pass never fails it retroactively, and a task never reviewed passes. Live,InspectTaskrefuses such a pass as rulereview, before T3: `open blocking review findings:; fix them (flywheel review --agent --fix) or dismiss one (flywheel review --dismiss --session --note " ")`. A non-pass verdict is never refused by it. - P1 — no inspected pass without a complete review panel (issue #420). Where the panel applied —
review.requiredis set, or an agentreviewedevent carrying a dimension preceded the pass — aninspectedpass fails whenVerdictMatrixover the events BEFORE it, on the pass’s tree, has a configured dimension that is notpass; the reason names each<dimension>=missing|correct. The panel andreview.requiredare read from today’s configuration; apanel_scopedevent on the pass’s tree narrows the panel to its own (panelFor, issue #459). Live,InspectTaskrefuses such a pass as rulepanel, after T3 (the tree it checks is the one T3 measured):the review panel is not complete on tree <tree>: <dimension>=<verdict>, ...; run flywheel review <task> --agent --panel --session <reviewer> (add --fix to correct and re-review). - W1 — no withdrawal of a live attempt (issue #479). A
withdrawnevent is legal after aplannedoramendedevent or after a finished attempt (any status butdispatchedorrunning); one the task’s events derive todispatchedorrunningjust before it (derivation order) fails withwithdrawn event at <ts> while attempt <attempt> was <status>; stop the run (or wait for it to finish) before withdrawing. Live,flywheel logrefuses such a withdrawal as ruleW1(exit 6) before anything is written.
3. Designed, not enforced
docs/design/autonomous-shipping.md describes ten transition rules, T1-T10, and a fuller event
vocabulary (audited, signal, dismissed, learning, “by” attribution blocks). Of the T rules,
T1, T3, T4, T5 and T8 exist in verify.go (alongside R1, W1 and P1, §2), and T7 and T9 are enforced
live by flywheel land (land.go, below). Only the kinds in events.go’s known-kinds map exist at
all — Validate rejects any other kind by name — and audited, signal, dismissed and
learning are all in that map, so each is a recognized record (audited is written by
flywheel audit, §1). Concretely, still design-only or only partly built:
- T2 (a step-20
worker_planor a signal) — theno-planhalf landed (§1);flywheel runnow records asignalevent forno-planand the other conditions, but nothing treats one as a rule violation. - T6 (sensitive domains need the lead’s sign-off and an audit before landing) — nothing detects a “sensitive domain,” and no command asks for a sign-off.
- T7 (a wave’s first article needs
audited conformsbefore the rest lands; an open nonconformance stops its kind of task) —flywheel auditnow recordsaudited(issue #61), andflywheel audit --first-article/--sample RATEselect first articles and a seeded sample (issue #61), and T7 is enforced byflywheel landwhen .flywheel/config.json setsaudit.first_article: a unit is refused (exit 6) until its worker line has a conforming audit, and while the line’s latest audit is a nonconformance. - T9 (checkpoint/land/handoff refuse while signals are untriaged, unless
allow_untriaged) — now enforced live byflywheel landfor a task’s own untriaged signals (refused with exit 6 unless--allow-untriaged <reason>records anallow_untriagedevent).flywheel handoffdoes not refuse yet, andflywheel verifydoes not re-check T9, because ledgers written before the rule existed have landings with untriaged signals and would all fail. The signals are not triaged byallow_untriaged— they stay listed byflywheel feedbackuntil a learning names them with--signals, and they recur untriaged again if the condition reappears after the learning. - T10 (the log is append-only with a hash chain per shard) — the log is append-only in
practice (
AppendEventonly ever opens withO_APPEND, andParseEventstreats an unresolved git conflict marker as a hard error). Every event carriesprev, the SHA-256 of the log’s last complete line when it was appended (issue #57);flywheel verify --logchecks that everyprevmatches the hash of some earlier line, failing (exit 6) at the first line whoseprevmatches none. Appends are serialised by.flywheel/events.lock(held only for the read of the last line and the write), so within one ledger the chain is linear: every record but the last is the predecessor of the next, and removing or editing any of them is detected. “Some earlier line” is accepted so a git merge, which interleaves two branches’ lines, stays valid. The per-shard half is built too: in the sharded layout each shard file has its own chain from the shard genesis hash, and@floor.jsonl’s seal pins the legacy file (verifyShardedChaininchain.go; see Sharded layout). Still not built: removing the LAST line of a file, or editing a line written before the chain existed, is not detected.
Until these land, the factory-role table, the andon cord, sampling, and nonconformance handling in
docs/design/autonomous-shipping.md and skills/flywheel/references/factory.md describe intent
and skill-level convention, not something flywheel verify can fail on.
4. Who may write which event
Verify’s T8 is the only persona check in the code, and it covers exactly three kinds:
validated/owns_checked→ persona must be"supervisor".flywheel validatehardcodes this on every event it writes (gauges.go), regardless of which session or identity ran the command —ValidateTasktakes no session argument and the event carries none, so nothing ties a reading to who typed it. “A worker never runs the gauges” is a skill-level convention here, not a mechanical block: the OpenCode worker permission policy (skills/flywheel/references/worker-permissions.json) denies only tree-rewriting git commands, notflywheel validate.flywheel attestalso signs its external readings"supervisor", and they are the one kind of reading that does carry asession: the lead’s, never a worker’s (T4).inspected→ persona must be"inspector"or"lead".flywheel inspect(InspectTask) always writes"inspector"; the only way aninspectedevent ever carries"lead"is a hand-craftedflywheel log --jsonline with an explicit"persona":"lead"field — the ordinary flag form offlywheel loghas no--personaflag at all.- Every other kind (
planned,dispatched,started,worker_plan,no-plan,finished,report,reviewed,blocked,lost,withdrawn,landed,amended,staffed,goal) carries no persona restriction inruleT8;withdrawnis the lead’s, throughflywheel log. In practice most of them are written only by a specific CLI command (dispatched/started/worker_plan/no-plan/finished/report/worktree_setuponly byflywheel run;landedonly byflywheel land;blocked/lostonly byflywheel controller;review_findingonly byflywheel review --agent;finding_responseonly byflywheel review --agent --fixand a lead’s--dismiss), which is what keeps them honest — not a persona field. The staffingreviewerrole (staffing.reviewer.adapter|model|session) names who run;landedonly byflywheel land;blockedonly byflywheel controller;lostonly byflywheel controller,flywheel runandflywheel next;review_findingonly byflywheel review –agent), which is what keeps them honest — not a persona field. The staffingreviewerrole (staffing.reviewer.adapter|model|session) names who runs the review agent;Validate` refuses a reviewer session that also holds the lead or inspector role, and the agent itself refuses (T4) a session that is a worker session of the task.
The one place “a worker never inspects its own work” is a real, live check rather than a skill
convention is T4: InspectTask and ruleT4 both refuse an inspected event whose --session was
ever a worker session (one that wrote that task’s started, finished, dispatched, report, or
worker_plan event). Put plainly, in this codebase: a worker never writes a validated,
owns_checked, inspected, or landed event because no worker-facing tool writes them and the
worker’s own permission policy does not need to block commands it is never given; the supervisor
(the gauges) never writes an inspected event because flywheel validate has no verdict to
record; and the inspector never writes a validated event because flywheel inspect never
touches gate output — only flywheel validate does, and it always signs its own readings
"supervisor", never "inspector".
Log layout
The event log has two layouts, both append-only: the legacy single-file layout (.flywheel/events.jsonl) and the sharded layout (files under .flywheel/events/). Both are opt-in; flywheel log --shard is the only way to switch, and the switch is one-way: once a repository uses the sharded layout, older binaries cannot read it safely (they reject the unknown log.shards config field). The config key log.shards is written by --shard and fences out older binaries.
Legacy layout
The legacy layout is a single .flywheel/events.jsonl file, one JSON event object per line, appended by AppendEvent.
Sharded layout
The sharded layout puts events under .flywheel/events/:
- Per-task shards: one file per task, named
<task>.jsonl, holds all events for that task (every event with that task id in thetaskfield). - Global events:
@floor.jsonlholds events that have no task (session_start,session_end,session_command,staffed,goal,lead_edit, andamendedevents whose brief is in the global briefs directory). - Session boundary events:
@session-<id>.jsonlholds everysession_start,session_command, andsession_endevent for that session, so a reader can reconstruct a session’s view efficiently.
Readers merge events in this order: legacy file first, then stable by each shard’s running-max timestamp (the timestamp of the last appended event in that shard), which preserves the global linearized order while allowing shards to operate concurrently. Each shard has its own hash chain with a genesis block (the first event appended to that shard) and a sharded event at the moment the layout is switched (kind sharded, with fields identical to other seal events). flywheel verify --log checks each shard’s chain separately and also the merged order across shards.
Transient locks under .flywheel/locks/ guard concurrent writes (one per shard, named <task>.lock); they are git-ignored. The locks enforce per-shard write order and are consulted by readers to detect in-flight appends, but readers never wait — a slow reader may observe partial state, and consistency is per-file, not cross-shard. Deletions and trimmed tails in a shard are not detected by the chain (the seal block lives at insertion time, not at mutation time); only appends are tracked.
Backups
flywheel ledger backup <path> (#464) is the supported way to keep an extra, point-in-time copy of the ledger; it is not an alternative to committing it (the ledger is committed with merge=union, and flywheel doctor warns when it is untracked or lacks that attribute). It copies .flywheel/ to <path>/.flywheel/ but leaves out the top-level worktrees/ and locks/ (live process state, not records), every *.tmp file, and symlinks or junctions (it lists them instead of following them). events.jsonl and every shard under events/ are copied by complete lines only: a trailing partial line still being appended is left out and counted. The copy is built in a temp directory next to <path>, then renamed onto it. <path>/flywheel-backup.json records the creation time, the source, each file’s path, size and sha256, the skipped paths, the partial-tail bytes, and the result of VerifyLogChain on the copy. A broken chain is recorded, not fatal. To restore, copy <path>/.flywheel back.
5. flywheel verify and exit codes
flywheel verify [<task>...|--all] [--json] [--log] [--workdir PATH] runs T1/T3/T4/T5/T8/R1/W1/P1 for the requested
tasks (--all derives the task list from every task seen in the log) and prints one
PASS/FAIL/INCONCLUSIVE line per rule per task, or the same result as JSON ({"passed": bool,
"items": [{"task","rule","pass","inconclusive","reason"}]}; inconclusive is omitted when
false, so --json consumers of the existing fields keep working). --workdir names the
repository to resolve tree objects in when the readings were taken in an external clone (issue
#244); without it, a workdir recorded on the task’s reading events is used when that path still
exists. --log checks the event log’s hash chain (T10, issue #57): every event’s prev must match
the SHA-256 of some earlier complete line; --log fails (exit 6) at the first line whose prev
matches none, indicating a line was edited or removed. When that dangling prev is the hash of a
LATER line in the same file, the break reason is reordered: line N chains to line M, which comes
after it (a git merge or an edit reordered the log; no record is missing). A git merge or conflict
resolution of a committed ledger does this. It is still a break, but no record is missing (issue
#422); otherwise the reason is prev matches no earlier line. Either layout prints
<file> line N: <reason> (prev <12 hex>).
Acknowledged breaks (issue #436). The ledger is append-only, so an explained break is never
repaired by an edit: flywheel log --reanchor --note "<why>" [--force] [--session S] [--dir DIR]
appends a reanchored event (floor level, no task) through the normal append path, so the
acknowledgement is itself chained. It carries file (the log file as the chain check names it:
events.jsonl or events/<task>.jsonl), line_no (the 1-based break line, display only),
break_prev (the dangling prev, full hex), sha256 (the break line’s hash — the identity of the
acknowledged line), reason (reordered when the break reason starts reordered:, else removed)
and note (why, required). The command refuses (exit 6) when the chain is intact, when the break is
not a dangling prev (a missing seal, a line with no prev, a wrong shard genesis), and when the
break classifies as removed without --force: a removed break may be a real edit or deletion,
and --force records the decision that it is explained. --reanchor does not combine with
--kind, --task or --json, and --note is required (exit 2). The chain check (all layouts) first
reads the log’s reanchored events; a break is acknowledged when one has the same file, the same
break_prev, a sha256 equal to the break line’s hash, and a reason equal to the classification
computed now — a reordered acknowledgement never covers a line that now classifies as removed.
An acknowledged break does not stop the scan; each later break needs its own acknowledgement.
--json lists them under acknowledged (file, line, reason, session, note), and a passing
--log and flywheel recover’s integrity line append ; acknowledged break at <file> line N
(<reason>), by <session>: <note> for each.
Preventing reorders. A repository that commits .flywheel/ should merge the event log with
git’s union driver, which keeps each side’s appended lines in order, so every prev still resolves
to an earlier line. flywheel init writes .flywheel/.gitattributes with events.jsonl merge=union
and events/*.jsonl merge=union; it never rewrites an existing file, so an older repository adds
those two lines to .flywheel/.gitattributes by hand and commits it. An INCONCLUSIVE item is pass:false with
inconclusive:true: the pass’s tree could not be resolved in any repository this verifier can
see, so T3 can neither confirm the readings nor assert a breach. Naming a task explicitly still
runs every rule for it even if the log has never heard of it — a missing planned brief, for
instance, fails T3 by name rather than being skipped. An empty log verified with --all passes
vacuously; verifying with no tasks and no --all is a usage error — except --log alone, which
checks only the chain.
Exit codes follow the repo-wide convention from AGENTS.md: 0 ok, 1 error, 2 usage, 5 gauges
failed, 6 rule refusal, 8 inconclusive. The enforcing commands:
| Command | Success (0) | Refusal | Other |
|---|---|---|---|
flywheel validate <task> |
every gate passed, nothing outside owns: |
5 — a gate failed, stayed host-blocked after one rerun, or a changed path is outside owns: |
2 usage, 1 other error |
flywheel inspect <task> --verdict ... --session ... |
inspection recorded | 6 — RuleRefusal naming T3, T4, T8, or review (an open blocking review finding) and its fix |
2 usage, 1 other error |
flywheel attest <task> --commit <sha> --evidence URL --session S |
external readings recorded | 6 — RuleRefusal naming T3, T4 or T5 |
2 usage, 1 other error (e.g., the commit is not in the repository) |
flywheel verify [...] [--json] |
every requested check passes | 6 — any check fails (FAIL <task> <rule>: <reason>) |
8 — every failing check is INCONCLUSIVE (no violation established, the tree could not be resolved); 2 usage, 1 other error |
flywheel land <task> --commit <sha> [--exception TEXT --session S] |
landing recorded, or repeats an already-landed commit | 6 — RuleRefusal naming T5 or T4 (T5 includes a commit off the integration branch or touching none of the unit’s files) |
8 inconclusive (the commit or every integration ref does not resolve: run git fetch), 2 usage (e.g., –exception without –session), 1 other error |
flywheel land <task> --correct <sha> --reason TEXT --session S |
land_corrected recorded |
6 — RuleRefusal naming T5 (no landed event, the commit is already the effective one, or it fails land’s commit checks) or T4 (a worker session) |
8 inconclusive (the commit does not resolve: run git fetch), 2 usage (a conflicting flag, or no –reason or –session), 1 other error |
flywheel run <task> |
rc == 0 and finish reason was stop |
— | 3 silent (no output within the start timeout); 7 stalled (the run-file gap watchdog fired mid-stream, issue #158); 6 suspended (a flywheel suspend --stop stopped the worker, issue #572); 4 any other outcome (nonzero rc, or reason length/error/start-failed); 2 usage or no worker configured; 1 other error |
flywheel run’s own codes (3, 4, 7, and 6 for suspended) are not part of the repo-wide list: they are
ExitCode’s reading of one Result, keyed by exit number instead of by command, and AGENTS.md
records them as run-specific. Exit 7 means stalled — a mid-stream gap — and nothing else, so
a consumer scripts exit codes per command, never globally:
| Exit | flywheel run reason |
|---|---|
| 3 | silent — no stdout line arrived within the start timeout |
| 4 | any other non-clean outcome — nonzero rc, or finish reason length, error, rate-limited (after any resumes), abandoned-job (after its one resume), or start-failed |
| 6 | suspended — a flywheel suspend --stop stopped the worker at a lease tick (issue #572); flywheel resume continues it |
| 7 | stalled — the run had started but the run file stopped growing for the stall timeout (issue #158) |
Everything upstream of these five commands — writing a brief, deciding what belongs in owns:,
choosing which task to dispatch next — is judgment the protocol does not check; the CLI enforces
only what is above.