پرش به محتوا

What you control

Every knob — the launch defaults and the ladder's caps and toggles — plus the full command reference for the engine and its helpers.

مستندات به انگلیسی نوشته شده است. بقیهٔ سایت به فارسی خوانده می‌شود.

This is the part most people miss. The plan is a plain markdown file, and a handful of lines in it are read by the engine. Change a line, change the behaviour. You can ask Claude for any of these in plain language at plan time, or edit the file yourself afterwards.

The control surface at a glance

You want to…Put this in the planWhere
Choose the executing model**Target model:** claude-opus-5## Session budget
Change how much work fits a session**Budget:** ~200K weight/session## Session budget
Use a different model for one phase- **Model:** haikuthat ### Phase N block
Say how big a phase is- **Size:** S | M | Lthat ### Phase N block
Turn QA on**QA gate:** on## Session budget
Turn QA off explicitly**QA gate:** off## Session budget
Commit to a specific branch**Branch:** feature/checkout## Session budget
Force skills into every session**Skills (every session):** `design-system` ## Session budget
Name the MCP servers every session needs**MCP servers (every session):** `context7` ## Session budget
Add a server only one phase needs (UNIONED with the plan's)- **MCP:** `github` that ### Phase N block
Park rather than continue when a server will not connect**MCP policy:** require## Session budget
Carve one phase out of a plan-wide require- **MCP policy:** continuethat ### Phase N block
Turn QA off (or on) for ONE phase- **QA:** off | on — the phase's word beats the plan'sthat ### Phase N block
Say which repos a phase touches (its scope — what decides concurrency)the Repos column; empty means all, which makes the phase run alone## Phase graph table
Say a phase depends on othersthe Depends on column## Phase graph table
Block a phase behind something external*(GATED)* + - **Gates (must clear first):** … (numbered steps for human gates)that ### Phase N heading
Say who can clear that gate- **Gate-check:** ai <check> (a session — prefer) | manual <who> (a person) | date 2026-09-01 (itself)that ### Phase N block
Clear / approve a gate (any kind)the phase page's Gate card, or gate-approve.sh <slug> <N> --by <who>the console · docs/handoffs/<slug>/gate-status.md
Retire a plan nobody will finishstatus: abandoned + a reason — set it with close-plan.shthe plan's frontmatter
Bring a retired plan backclose-plan.sh <slug> --reopenthe plan's frontmatter
Put a console run on one work branchSettings ▸ Automation ▸ Branch (or the launch form)the console
Give a run its own checkout, so two plans in one repo drive at oncethe launch form ▸ Give this run its own checkout (needs the work branch)the console
Say what a finished branch becomesthe launch form ▸ When the plan completes — PR · merge queue · integration · keepthe console
Cut this run's branch from a chosen base, cap the runs beside it, say what becomes of its checkoutsthe launch form ▸ Branch and checkout ▸ Base branch · Runs beside it in the repository · When the run settles, its checkouts — each speaks only where the plan's **Base branch:** / **Repo capacity:** / **Worktree retention:** line is silent, and each opens on the Settings ▸ Automation default; the base is refused (409) once the branch exists, the cap is clamped to the console's ownthe console · the plan
Read where every lane was cut froma plan's Phases table and its Run view's git fold ▸ the branch's title — Cut from <ref> at <sha> (which git arm; who declared it), from the run record's basethe console
See that a lane's tree is lockedthe Run view's git fold ▸ locked by the console (or locked by hand), its title the git worktree lock reason, on git's word (ChildRef.locked); absent means no lockthe console
Bring a stack up before a phase can be proved- **Setup:** in that ### Phase N block (or one **Setup (every phase):** line in §Session budget)the plan
How long a session may sit on its OWN background job before the console parks it — measured on the wait CHAIN (calls of one probe, each within WAIT_CHAIN_GAP_MS, 3 min, of the last), so the wait procedure's 10-minute slices add up; silent until then, one push when it passesSettings ▸ Automation ▸ stallLocalJobMs (45 min shipped)the console
Bound the console's own checkouts (cap, setup command, .env copy, branch reclaim)Settings ▸ Automation (shown only while isolation is on)the console
Delete a run's pe/* branches once their PR has mergedSettings ▸ Automationthe console
Open a PR when the plan completesSettings ▸ Automation ▸ Open a PR (needs the work branch)the console
Queue runs whose repos overlapSettings ▸ Automation ▸ Repository guardthe console
Let the console heal a stopped run by itselfSettings ▸ Automation ▸ Auto-recover halted runs (and the ladder card's toggles)the console
Bound what it may spend by itselfSettings ▸ Automation · the ladder ▸ Caps (rungs and dollars per phase / run / day)the console
See who is in the repository, and queue behind themSettings ▸ Automation ▸ Session presence ▸ Install (or phase-console install-hooks)the console · ~/.claude/settings.json
Answer every decision a run could ask BEFORE it startsthe launch form's Decisions stage: the manifest with each row's state, the five pre-spawn probes (accounts · MCP · credentials · delivery · verification commands — approve an exact command or waive a check, once, before anything spawns), the four required answers (resumeOnRestart, relay, the accounts with their minimum headroom, the acknowledged waivers) and the one recorded override (run.manifest-override); the start door answers 409 on an open blocking row (GET /api/run/<slug>/prelude is the same computation)the console · the plan's ## Decisions
Check a console and its machine by handphase-console doctor [instance] [--json] — the prelude's probes with no plan in front of them, then the machine, one row each: accounts, mcp, credentials (the machine claude login), delivery, hooks, cli (the Claude CLI version against the relay floor), gh (gh auth status), environment and console. It asks the console answering on the instance's port (GET /api/doctor, which is what --json prints); with none it reads the state directory and the machine (mode: offline), and a row only a running console can answer says skip, never ok. PATH advisories print as ↳ warnings under their row, not failures. Exit 1 names the first failing row that blocks — accounts, credentials, hooks, environment, or console when the console answers and reports itself degradedthe console · the machine
Answer a decision class for every plan on this consoleSettings ▸ Automation ▸ Policy answers — the policy table's rows (one control each, the closed words or one line of text), with a plan picker that shows that plan's own row and the answer in force with its source; every change is policy.changed {key, from, to, by}the console (policy.<key>)
Keep what a session decideda keyed ruling (phase-outcome.sh … ruling --needs <key>) is an inbox row with Remember for this plan (a ## Decisions row, source ruling, through decisions.sh promote) and — when its words are an answer the console can hold — Remember on this console (policy.<key>); the session itself can do the first with --remember plan and ask for the second with --remember global (POST /api/run/<slug>/rulings/<id>/remember {scope: plan|global})the inbox · the plan's twin · the console
Answer a relayed question the same way on every runSettings ▸ Automation ▸ Policy answers ▸ Relay rules (relayRules), or Remember as a relay rule on an answered question's inbox row ({scope: rule} on the same route) — see Relayed questions and relay rulesthe console · the inbox
Answer a lane's session that stopped to ask youthe inbox's session-ask row on an autopilot lane ▸ Answer it — your words reach that lane's session as an instruction (POST /api/run/<slug>/steer {phase, instruction}, --allow-run)the inbox
See how often a person was asked, and how often auto-grant answered insteadInsights ▸ Who was asked — the instance's approvals/counter.json (raised, autoGranted, since, lastRaisedAt), carried by GET /api/state as approvals with the pending count beside itthe console
Know when the policy in force cannot ask, or the deny wall is struckSettings ▸ Permissions shows an acknowledgeable banner per advisory (ask-empty · deny-struck), logged once per boot as policy.advisory, carried by GET /api/policy as advisory, acknowledged with POST /api/policy/advisory/acknowledge {kind} against the rules it namedthe console
See every act only you can do, in one placeYour turn: #/turn (#/approve and a push land on it) — six sections, each item with why only you, its guide as steps, how it will be checked and one primary action; see Your turnthe console
Answer a decision, decline an item, ask about it, attach evidencethe item's card — POST /api/human-steps/<id>/answer, …/decline, …/ask, …/evidence; an answer resumes each waiting session oncethe console
Have a person's act checkedthe item's I've done this — check (POST /api/human-steps/<id>/check); Settings ▸ Automation ▸ Check what a command cannot, Checking model and effort and Send to the owner afterthe console
Answer a permission the AI lacksthe permission item's Grant — This call · This phase · This plan · This repository · Always — or I'll do it myself or Deny (POST /api/human-steps/<id>/grant, …/convert, …/deny); the never list offers no grantthe console · grants.ndjson
See what was granted, and take it backSettings ▸ Permissions ▸ Grants, or phase-console grants list, grants revoke <id>, grants revoke-all (GET /api/permissions/grants, POST /api/permissions/grants/<id>/revoke, POST /api/permissions/grants/revoke-all)the console
Prove a press is yoursan owner key: phase-console owner enroll, then Settings ▸ Permissions ▸ Owner keys; owner status reads the door and owner lock ends every owner sessionthe console · owner-doors.json
Stop the console, as hard as you mean itSettings ▸ This instance ▸ Shut down (mode: exit) or Stay off… (mode: unload) — each opens a dialog listing what the console is holding; see Shutting the console downthe console
Send every announcement to a chat channelSettings ▸ Notifications ▸ Channels (needs --allow-webhooks)the console · docs/webhooks.md
Let the console push a finished phase's branchstart it with --allow-publish AND let the plan's permission.destructive row allow git push — both, or the landing parks with phase.landing-push-refused; phase-console doctor has a publish row saying whether a push is possible at allthe start command · the plan's ## Decisions

Pro adds one more want to that table: let the console file an issue a session drafted. A session that trips over a problem outside its phase records a draft (scripts/phase-issue.sh, one line in runs/<instance>/<slug>/issues.ndjson, never gh), and the plan's Issues: word decides what you see — under draft the inbox draws an Issue draft row (needs-you) per draft awaiting you, with Approve (POST /api/run/<slug>/issues/<id>/file, needs --allow-issues, which --allow-publish implies — without it the row shows the flag and the route answers 403), Discard (POST /api/run/<slug>/issues/<id>/discard), Edit title and Rewrite body (POST /api/run/<slug>/issues/<id>/edit, both with your words — these two and Discard need only the console header); under file the console files at once and you only hear of it; under off — the default — the script refuses and nothing reaches the inbox. A close draft waits for you whatever the word, and where the phase lands by policy, not before its landing has landed — until then it is an fyi row (pending-landing) with Discard alone. GET /api/run/<slug>/issues lists a run's drafts with every state they reached; the push category issue (on by default, never urgent) announces a draft that needs you and what was filed, commented or closed; and the repository page's Filed by control (#/repo/issues?filed=sessions) keeps the issues a session drafted, each with a chip to its phase. The journal names every step, phase.issue-drafted … phase.issue-filed (docs/journal-events.md).

Pro's launch form carries two more groups (many-plans-one-repo phase 15), each a word the plan outranks: Landing and conflicts — When a phase settles (landing: hold · integrate · pr · trunk) and When a landing will not merge (conflictPolicy: halt · park · rebase-session), the run's answer where the plan's **Landing:** / - **Land:** and **Conflicts:** lines say nothing; and Messaging and issues — Sessions may message each other (messaging: on · off) and Issues a session finds outside its phase (issuesMode: off · draft · file), where **Messaging:** and Issues: are silent. A pr or trunk landing names --allow-publish in its hint when the console was started without it, and a file word names --allow-issues. A run's off is no word of its own: the run then files under Settings ▸ Issues ("File problems a run finds outside its phase", on as shipped — off, such a run puts what it finds under Outstanding), so a run that must file nothing is launched at draft. Mid-run, a live run's settings patch moves landing and conflictPolicy both ways, tightens issuesMode only — judged by the word the run actually files under, so on a console that files, draft → off is a widening; the picker refuses it and says why, and the door answers 409 — and refuses baseBranch with a 409 once the branch exists. Settings ▸ Issues also holds "Also file improvement suggestions" (off as shipped: one suggestion a phase, about code the phase touched) and "Where the console's own problems go" (an owner/name; empty is the runtime copy's own origin). The run page then shows what came of it: the phase table's Land: chip (the plan's word, with where it came from; nothing for the shipped hold) and its landing state chip (the ledger's word, the engine's step on hover — a parked landing in the amber a person owns), and the Landings card — one row per phase with a landing: policy, state, step, each repository's branch, pushed sha and pull request link, the ledger rows behind a disclosure, the raw record behind that, and for a parked landing the engine's own sentence naming the other lanes and the files. The inbox draws a session's message to you as a Message row (needs-you for an ask, fyi for a note, urgent for a high-priority ask) with Answer — your words, through the operator's door (POST /api/run/<slug>/messages/<id>/reply, needs --allow-run) — and Mark seen; and the run page's compose box addresses only the phases the board does not call done. The phase drawer carries the forward notes left for that phase and the issues its sessions filed, each linked.

Changing a run's settings mid-run

POST /api/run/<slug>/settings patches a run in flight or a stored one, and each field lands when SETTING_EFFECTS (viewer/shared/run-settings.js) says — not all of them at the next phase. Takes effect now is the loop's next decision: an admission (maxParallel, maxConcurrentPerRepo, onlyPhases, priority, runBudgetUsd), a failure count, a wall (onLimit, accounts), a rung, or the permission hook's next call (permissionProfile). From the next phase to board is a session's spawn — its model, effort, skills, servers, mode, budget cap — so the session running now keeps what it started with. At the next phase-finish is the reviewers, QA's dispatch and the landing; when the run settles is settle and openPr; resumeOnRestart waits for the console's next boot. The sheet prints each field's word beside it.

The plan's QA gate is patchable too (qa): on needs the body's confirm: "creates-test-status" and --allow-writes, creates test-status.md with every phase that finished before it recorded waived, and writes the plan's **QA gate:** on; off writes the line off. The sheet opens while a lane is still working. What a patch cannot carry is refused BY NAME in one 409 — refused: [{field, why, verb?}] beside the run as it now is — and everything else in it is applied: gitMode and isolation while a lane is in flight (re-asserting the value the run already has is never refused), qa without its confirmation, and accountId, which moves through its own verb, switch-account. Every patch, live or stored, journals run.reconfigured {patch, before} with who asked.

Who a press is recorded as

Every verb that reaches the console over HTTP — a button, curl, the CLI — is recorded under an actor derived from the request itself (actorOfRequest), never from a name the client volunteers alone:

  • via — cli when the User-Agent begins phase-console or btw, otherwise api.
  • origin — local for a loopback Host, otherwise that hostname.
  • remoteUser — the tailscale-user-login header, read only under --remote and only for a request that did not arrive on loopback; without --remote nothing has vouched for it.
  • by — the body's by label when it offers one (trimmed, at most 64 characters), else the proxy's login, else operator for a browser or the CLI, else script for anything else. It is only a label.
  • pressDoor (control-tower phase 131) — the door the request came through, decided by what it can prove and never by what its body says: owner, device, local or session; the console's own writes carry checker or console (the doors are below). A script that says by: operator is recorded local, with its label.

The app sends no by of its own, so a press in the browser reads as operator locally and as the signed-in login through the proxy. Shut down, Stay off, Release, the ruling routes, the account clearance and the account check all carry this actor into their log lines and journal rows.

What a press answers

Every verb answers with the act it caused, or refuses with the reason — never 200 and nothing (control-tower phase 53, #54–#56):

  • Retry, Finish in its own session, Resume with an instruction and Delegate answer {run, launched}: the run, the phase, the session it resumed — null when it boarded a FRESH one — the brief, and why the phase's own session was not resumed. A refusal is a 409 with the sentence, and the session holding the phase when a live recovery is the reason.
  • A resume the policy will not resume (a session too large and cold, checkpointed, or declared partial --reason budget|context) boards a fresh session with the resume brief, your instruction and the policy's reason. It used to answer 200 and launch nothing.
  • A person's press sits in the person slot. Retry — and a resume or closeout boarded fresh — is accounted OUTSIDE the automatic ladder: journalled phase.rung {slot: 'person', by: operator}, it never reads a rung cap and never counts toward one, so the ladder cannot re-park the phase it just boarded. Its dollars still count.
  • Delegate to the session is the lead action for a phase parked needs-human --needs human-acts — acts the plan keeps for a person. It resumes the phase's session (or boards it fresh) with an instruction that the acts are the session's now, and writes a deviation ruling under human-acts naming you (phase.delegated). It does not move the permission wall: an act the policy refuses comes back as blocked --needs permission, and the widen card answers that. Free tier.
  • Pause, Resume (a pause taken back), Hold, Release and Skip answer 409 when there is no run to act on, instead of 200 {run: null}.
  • Switch account refuses a target the quota door refuses — retired, a learned wall, a spent window — with a 409 before anything is checkpointed. On a live run the switch is journalled, added to the run's account pool and saved at once, so it survives the loop's next write.
  • POST /api/prefs with a null value returns that key to its shipped default — the one way to take an override back. A key with no shipped default is still dropped.
  • A person's moves on an item answer the item as it now reads, or refuse with the sentence and a status: 409 when the item is not open or the verb does not fit its kind, 403 where no grant is offered or the door may not make the press, 400 for a body the verb cannot use — a grant at a scope the item does not offer, a high grant whose rule was not typed back ({rule, blast} carries the server's own blast radius), a body that carries a secret.
  • A press an owner key reserves answers by door (control-tower phase 148). On a console with an owner key, a press through a door that may not make it is neither applied nor dropped: it answers 202 {requested: true, request, error} and waits on its item as "asked by … — confirm?" for the owner. A high-risk press through the owner's door without a touch of the key inside five minutes answers 401 with reassert: true. With no key, an agent's door — a session's token — answers 403 {error, door, press, authority} before any route runs, logged owner.door-refused, unless the plan's manifest names the press; with a key, a session's token is recorded as a request like any other door's, and the owner-key routes refuse an agent's door either way. A console with no key behaves exactly as before.

Console automation defaults

The console keeps its automation preferences in Settings ▸ Automation (stored per instance under ~/.config/phase-console/). Ten of them are the opening values a launch surface starts from — attachDefaultSkills, qaByDefault, gitMode, openPrOnComplete, isolation, settle, reviewEachPhaseByDefault, reviewerPolicy, autoRecoverByDefault, mcpPolicy (the keys of buildPrefs() in screens/launch/model/modes.ts). Each launch can override them for itself, and how many a given surface even shows depends on its mode — a recovery ticket offers five fields in all, of which attachDefaultSkills is the only one of these ten. The preferences are where "for all plans" is said once. The others in this table — the repository guard, the run-branch and merged-branch policies, and the ladder card — are console-wide policy rather than launch values, and say what the autopilot may do by itself once a phase stops short, and how much of it (The loop is the specification). Every one round-trips through POST /api/prefs.

PreferenceDefaultWhat it does
Attach default skillsoffSeed the machine's --default-skills list into new runs, and pre-tick it in launch dialogs.
QA by defaultoffLaunch surfaces open with the QA gate ticked, so starting a run activates QA for the plan (needs --allow-writes; earlier finished phases are backfilled waived).
Branchcurrent branchWork branch per run puts every console-minted session of a run on one plan-wide branch, pe/<slug> — created from the default branch if missing, reused by later phases.
Open a PR at completiononWork-branch runs only: the plan's last phase is told to push pe/<slug> and open a PR per scoped repo. For that run — and only that run — bare git push moves from the deny wall to an approval card, and gh pr create stays a card even under the trusted profile; force-pushes and --delete stay denied outright. Auto-grant never answers those two cards: each raises a real card for a person unless the plan's permission.destructive row names the rule as an exception, and a grant under that exception is announced.
Repository guardonThe scheduler queues runs whose repository scopes overlap. Off: overlapping runs may start together, and a work-branch run sharing a repo with a live one is told to work in a linked git worktree instead of switching the shared checkout.
Take the run branch back from a clean checkoutClean onlyIsolated runs only: a checkout sitting on pe/<slug> with nothing uncommitted in it is switched to the default branch so the run can have its own tree. A checkout holding work — including a file nothing ever added — is never touched, and the run refuses branch-in-use naming those files. No branch is deleted and no ref moves. Never turns it off.
Isolated runs per repository3How many of this console's runs may hold a checkout in ONE repository at a time (maxConcurrentPerRepo). The machine-wide cap beside it bounds disk; this one bounds how much is happening in a repository a person may have to read, and the narrower of the two answers first. A fourth run waits on a repo cap holder — named on the queue, never a park — while a run in another repository starts at once.
Serialise conflicted branchesoffWhen the repository's conflict radar measures two LIVE branches as conflicted, the phase the landing order puts second waits behind a radar holder until the first has landed, and the wait is journalled phase.radar-hold with the pair and both orders (radarSerialize, many-plans-one-repo phase 9). The landing order is the landscape's — topological over Depends on, ties broken clash-zone first, then radar overlap, then FIFO — so the queue and the map agree. Off (shipped): the radar stays advisory, exactly as before; the pair is shown on the git card and the landscape and nothing waits. An overlap never serialises under either setting. A free console has no radar, so the switch does nothing there.
What becomes of a run's worktreeKeep on failurekeep-on-failure keeps a red run's checkout, because it holds the only copy of what went wrong, and removes a green one's, which holds nothing its branch does not. prune always removes, keep never does, and ttl:<h> removes a clean, landed, unlocked tree after that many hours. A dirty tree is never removed under any word — uncommitted work outranks every retention setting there is.
Base branch of pe/<slug>origin/HEADWhat a new run branch is cut from. origin/HEAD is the remote's own default branch, falling back to the local trunk; head is whatever the checkout has out (the behaviour before this setting existed); anything else is a ref, verified — and a ref that does not resolve forks nothing new and says so, rather than silently using the trunk. A plan's own **Base branch:** outranks this. The resolution is pinned to a COMMIT, so a trunk that moves between the decision and the checkout cannot move the run's starting point.
Delete merged run branchesonAfter a pr settle, a run's pe/<slug> and pe/<slug>-p<N> are deleted once the trunk contains them. Always git branch -d — git's own refusal for an unmerged branch is the safety, and -D exists nowhere in this codebase — so a squash-merge the local trunk has not fetched keeps its branch until the next drive.
When an MCP server is unavailableContinue and warnThe phase boards without the servers that would not answer, its prompt names them and tells it to record the gap under Outstanding as an errand, and you are told once per run per server. Park the phase is the older behaviour — use it when the work genuinely cannot proceed, remembering that a run whose ready phases have all parked has nothing left to start. Settable per run in the launch dialog and per phase in the run's phase matrix; a plan's own **MCP policy:** require outranks both the run choice and this preference.
Auto-recover halted runsonNew runs opt into the ladder (autoRecoverByDefault): a stopped phase is classified and climbed by itself, within the caps below; off, every stop is yours.
Continue runs a recovery fixedonWhen a recovery leaves the board reading fixed, the run resumes by itself (autoContinueRecovery).
Rungs per phase · Spend per phase3 · $100The ladder's per-phase caps (ladderPerPhaseRungs, ladderPerPhaseUsd) — how many rungs one phase may climb and what they may cost before its errand is written. A rung the ENVIRONMENT ended — the API refused the run's credential, the network was down, a transient stop came before the session's first turn — is recorded with cause: environment and spends neither the rung count nor the same-rung-once rule (it comes back when the machine does, at most five times on one phase); its dollars still count.
Rungs per run · Spend per run10 · $400The per-run caps (ladderPerRunRungs, ladderPerRunUsd); the one automatic budget raise stays inside the USD cap. The rung cap counts only the phases the board does NOT read done — a rung spent on a phase that has since closed stops being charged to the ones still open — while the dollar cap counts every rung the run paid for. A run can carry its own ladderPerRunRungs and ladderPerPhaseRungs (Recovery rungs per run and per phase in the launch dialog's Stops — on a start, a Continue, or the run's settings mid-run), which beat these; 0 is a cap under which nothing climbs, and emptying the box on a run that exists hands it back to these. A spent cap's errand names the setting, the arithmetic and how much of it sat on done phases.
Spend per day$600Across every run this console drives in a day (ladderPerDayUsd).
Sweep every5 minHow often the convergence loop re-reads every open plan even when nothing happened (convergeEveryMs; 0 turns the timer off — boot, a docs change and the minute after a stop always run a pass).
Park on a required MCP server for30 minA phase parked by the require policy continues without the server after this long, an errand recorded (mcpRequireTimeoutMs; 0 waits for it to heal, however long).
Raise a spent run budget once by25 %The resource ladder's one budget raise, within the per-run cap (budgetAutoRaisePct; 0 never raises).
Unblock attemptsonA handoff marked blocked for a reason no machine category fits gets ONE bounded session allowed to do the unblocking work — then an errand (unblockAttempts).
Take over stale claimsonAn expired foreign lock over unfinished work is taken over and the work continued; a live session's claim is never touched (staleClaimTakeover).
Resume killed lanes at bootaskWhat this console does about work its own restart stopped — a killed lane, a run a shutdown stopped, a wait whose clock went by while nothing ran (resumeAtBoot: ask · auto · off). A run's own answer comes first: the launch form's resumeOnRestart continues (true) or writes the errand (false), and only a run carrying neither falls through to this preference. Ask — the shipped default — starts nothing and puts the question in front of whoever opens the app next, naming the runs and phases, and a continue there outranks a stored false; Always resumes without asking; Never writes the errand straight away. A resume a live console makes on its own clock is the session's own declaration and is not asked about. Every automatic resume of a phase, restart-caused or not, is counted on one per-phase counter (bootResumes, read by automaticResumeGate), and at 3 (MAX_BOOT_RESUMES) the next one is an errand whatever woke it. The question is asked once per console start, and answering it is not stored — a restart is the event that makes it new again.
Switch accounts at a wallonA signed-out run account, or a usage window too far out to sleep on, switches to a registered account that can pay (autoAccountSwitch); off, the wall halts with an errand.
Let a session clear an overdue gateonA gate whose deadline has passed (OVERDUE) is briefed to the phase's own session to VERIFY its conditions against citable evidence and clear it, instead of stopping the run for a person (delegateHumanGates, this console's word for gates: delegated). A gate the plan marks manual is a person's whatever this says (control-tower phase 107, #174): never delegated, and approved only from the Gate card or the person's own terminal — "the owner approves the visual result" is not a thing a session can judge. What makes delegation safe is not trust: the brief requires cited evidence per condition and STOPS with the condition named when it has none.
Policy answersnoneThis console's answers to the decision manifest's rows, keyed by decision key (policy, an object; shared/policy-model.js DECISION_ANSWERS, one line of text for the free-text keys). Read below a plan's own ## Decisions row and above the shipped defaults — gates: delegated · qa.exhausted: waive · resume.on-restart: continue · ambiguity: ruling. Edited row by row on the Policy answers card (Settings ▸ Automation), written whole through POST /api/prefs, every changed key journalled as policy.changed {key, from, to, by}; a ruling remembered on this console lands here too.
Board a phase that states no verificationoffA phase whose plan omits the §Verification bullet boards anyway and passes on its handoff alone (allowUnverifiedPhases), instead of parking at boarding with "add a §Verification command, then Retry". The record says phase.verify-waived, so nobody reads "0 commands green" as proof, and a bullet the runner cannot read still parks — that is a formatting fault the author should hear about.
One more rung while the work is movingoffWhen a phase has spent its rungs but the newest settled rung landed commits, the ladder is granted ONE extra rung for that phase (ladderExtendOnProgress, journalled phase.ladder-extended). Once per phase; the dollar caps stand.
Run cmd: watch refsonA session's --watch cmd:"…" is run on the console's own timer to see whether what it waits for has landed — under the read-only policy a plan's §Verification gets, 60 s, never a shell (watchCmdRefs). Off: such a ref is never run and nothing resumes on it.
Run cmd: refs the console mintedoffWhen the watchdog parks a lane that was polling inside its turn, it files the command it was polling with as a watch ref of its own (watchMintedCmdRefs). Off: that ref is recorded and never run — the console's own inference does not execute a command against a repository nobody is watching — and the park still resumes on its clock. On: minted refs run exactly as declared ones do.
Park a waiting lane by itselfonA lane waiting inside its turn — an open poll loop, or a wait the console refused — is checkpointed and parked in the console's own name, on its own allowance and never the session's declared waits (stallAutomaticPark). Off: the stall card still stands and the local-job nudge still goes, but nothing is parked.
Automatic starts per hour · Session spend per hour40 · $250The ladder card's Start ceiling (ceilingStartsPerHour, ceilingUsdPerHour). Every claude this console starts by itself — a boot re-adoption, a wait clock, a converge relaunch, the MCP probe, the reviewer — is counted over a sliding hour, and so is what the sessions that ended in that hour reported costing; past either, the next automatic start is refused by name (run.start-refused {door, ceiling, limit, count, until}) and announced once per window. Your own Start, Retry and Continue are never counted and never refused. 0 turns a limit off.
Relay rulesnoneThis console's standing answers to relayed questions (relayRules) — see Relayed questions and relay rules.
Boarding scheduleoffWhen this console is willing to START phases (boardingSchedule). See below.

The three checkout defaults above — the base branch, the runs beside it, the checkouts' retention — are rendered by the same launch form the run opens on (<RunSetup mode="defaults">, many-plans-one-repo phase 15), so the words an operator reads when setting a default are the words they read when starting a run; each launch may override them for itself, and a plan's own line outranks both.

Pro's defaults card carries four more launch defaults on the same form — When a phase settles (landing, shipped hold), When a landing will not merge (conflictPolicy, shipped halt), Sessions may message each other (messaging, shipped on) and Issues a session finds outside its phase (issuesMode, shipped off — outward writes are never a default) — each saved under its own key the moment it changes, and each spoken only where the plan is silent. Beside them one line states whether --allow-publish is on: a start flag, never a preference, and the one thing a pr or trunk landing and a file issue mode need that no default can grant.

The boarding schedule

Every other clock on this page is the machine's — the session cap, the account's usage window, the scopes. This one is yours: is now a time I want sessions starting at all. Nothing could infer it, and a laptop is perfectly capable of boarding an eleven-phase plan at 03:40.

Three rules, composed in this order and no other:

RuleKindWhat it means
Boarding windowsallowfrom–to local time, optionally restricted to certain days. With none set, every hour is allowed. A window whose to is not after its from runs past midnight and belongs to the day it STARTED on — 22:00→06:00 on Friday is Friday night, and covers Saturday 02:00.
Cron openingsallowFive-field expressions in local time; each match opens boarding for cronMinutes (60 by default). The same allow-list, said in the vocabulary a crontab already uses — including Vixie's day-of-month/day-of-week union, so 0 0 1 * 1 means the 1st and every Monday.
Quiet hoursdenyNothing boards inside these, whatever the windows say. Quiet hours win; that is what makes them worth setting. (A device's quiet hours — when a phone stays silent — are a different, per-device setting: docs/phone.md, Step 6.)

Outside the schedule a ready phase queues rather than failing: the queue names the boarding window as what it waits on and says when that opens, and the phase boards on the very next scan after it does. The console wakes at the opening rather than polling for it.

Two deliberate exemptions:

  • A recovery you ask for is never held. The schedule governs what the AUTOPILOT starts. Re-check, Finish in its own session, Resume with an instruction, Retry and Fix with a new agent all run immediately, because pressing a button at 02:00 is asking for it. Never held is not never refused, though: a recovery press over exactly the evidence the last recovery of that phase ran under (the board, the handoff, the locks, the gate) is refused as unchanged, and one past six recoveries of a phase in a run as capped (RECOVER_MAX_PER_PHASE) — both journalled run.recover.refused, the sentence naming the recovery it repeats. Retry starts the phase over and clears the count — and replenishes the phase's ladder: the interruptions and environment-caused rungs it counted are forgiven (phase.ladder-replenished), so a remedy the console's own restarts or an outage burned is climbable again. A rung that ran and failed on its merits stays tried, and no dollar is given back.
  • A live phase is never interrupted. The schedule decides whether a session may START; a session already running when quiet hours begin runs to its own end. Stopping work mid-edit to keep a timetable would leave a working tree nobody owns.

An unreadable window or a malformed cron expression is dropped, never defaulted — a schedule nobody can parse must never silently become "board at any hour". The card refuses a bad expression out loud rather than swallowing it.

Beside the preferences, every launch surface offers two per-run choices when accounts are registered (--allow-accounts): Account — which Claude login the run's sessions spend, including auto (most 5-hour headroom) — and On usage limit — switch (checkpoint and continue at once under the account with headroom; the dialogs' default), wait (sleep to the reset and resume by itself, restart-safe), or pause (checkpoint and stop for a person). A model-specific limit keeps switching models, not accounts — and files its wall under the model's own bucket, so auto skips that account only for runs of that model. Switch account on a live run acts immediately and lists every account with the current one marked; the scheduler throttles only the limited account. Accounts rename (display name only) and remove from Settings; an expired login raises a Sign in again alert and a run pinned to it is refused at preflight instead of burning sessions.

Each credential also carries where it stands with the organisation that pays for it — unknown · entitled · cooling · retired (ENTITLEMENT_STATES), learned machine-wide and keyed by organisation as well as by account. A usage wall leaves an account cooling until the wall's own reset (30 minutes when none parsed, ACCOUNT_COOLDOWN_MS); a credential-class refusal — an organisation policy, a billing hold, a revoked key — leaves it retired, and every account sharing its organisation with it, until a person clears it. Two routes, both behind --allow-accounts and both attributed to the request that asked:

  • POST /api/accounts/<id>/clear-retired — the one door out of retired: the credential and its organisation go back to unknown, and the next successful meter read or spend proves them again.
  • POST /api/accounts/<id>/probe-entitlement — asks the API directly, with one declared one-turn session under that account, and waits for the answer. A credential-class refusal retires it exactly as a phase's refusal would; any answer — a usage limit included, since the API took the credential and then counted — promotes unknown to entitled; a session that could not answer moves nothing. 409 while a check is already running for it.

An account whose login broke is repaired where it was diagnosed, whatever its kind — also behind --allow-accounts:

  • POST /api/accounts/login {accountId} — signs a profile in again in the embedded terminal, and the machine login with the CLI's own claude auth login and no CLAUDE_CONFIG_DIR (the command comes back to copy when there is no terminal). While a live run pays as the machine login it answers mode: "warn" with the runs and the identity they are bound to, and runs nothing until the body carries confirm: "ends-live-sessions". For a token it answers mode: "replace-token".
  • PUT /api/accounts/<id>/credential {token} — replaces a token account's credential and keeps its id and name; what the machine learned about the old token goes with it. Adding a token under a name an account already has is a 409 whose existing names that account.

Every account's view carries meter (ok · broken · unsupported · none) and paying — the live runs set to pay as it.

The buttons for both are on the fleet app's accounts view, per console.

Relayed questions and relay rules

A run's relay is one of RELAY_MODES — off · last-resort — answered on the launch form's Decisions stage. Under last-resort, a question a session raises through AskUserQuestion reaches you as a card held open for its window; unanswered, the console answers it by its relay rules when the window closes (The loop has the window and the order the console answers in). Under off the console answers nothing on the session's behalf, and the run carries --permission-prompts none. Answering from anywhere but the card is POST /api/run/<slug>/answer {approvalId, answers: [{key|question, label}]} — at most four picks, behind --allow-run like the permission card beside it, and 409 once the window has closed.

relayRules is a list of {id, tool, key, profile, answer}:

  • key — a glob (* spans anything), matched case-insensitively against the question's key (questionKey): its header slugged to 24 characters, a colon, its text slugged to 60.
  • tool — the asking tool, AskUserQuestion when omitted; profile — the run's permission profile, * (any) when omitted; id — derived from the other three when not given.
  • answer — an option label, matched exactly or as the unique prefix of one, case-insensitive (Blue answers Blue (Recommended)). A rule whose answer names no option of the question does not apply, and the answer falls through to the recommendation.

A malformed rule is dropped rather than repaired — a repaired rule answers a question its author never wrote it for — and at most 50 are kept (MAX_RELAY_RULES). The table is edited on Settings ▸ Automation ▸ Policy answers ▸ Relay rules; an answered question's inbox row offers Remember as a relay rule, which is POST /api/run/<slug>/rulings/<id>/remember {scope: rule} — it writes this preference rather than a ## Decisions row, so it carries the mutation guard alone, like global — and the rule it writes matches that question on any profile.

What it does by itself, and what it asks you

Since 2.3.0 a stopped phase is not a dead end. The console classifies it — never started, work in progress, done but unrecorded, verification red, declared blocked (lock · credential · gate · external · unknown), a resource wall (usage · auth · budget · model), an unreachable MCP server, a broken plan, a stale or a live foreign claim, a manual gate, a QA verdict — and climbs that situation's ladder: its own session first (--resume), a fresh briefed session next, an account or model switch at a wall, one bounded unblock session on a declared blocker, a takeover of a stale claim. The convergence loop runs it at boot, on a docs change, every sweep, a minute after any stop, and on Recover & continue. When every rung is spent — or the situation was yours from the start — it leaves one errand: what is needed, how to give it, what was already tried. You see the ladder on every Ways forward group (the situation chip, the rungs tried, the next rung, or the errand card), the errand among the items on Your turn, each plan's last pass on the Pulse's Converge line, and the caps and toggles above. Sessions see each other through the session-presence hook (Settings ▸ Automation ▸ Session presence, phase-console install-hooks): a hand-run claude in the repository shows on the Pulse, its lock is a queue to wait in while it lives and debris the moment it ends, and its phase-outcome.sh declarations drive the same machinery as a lane's. What stays yours: a sign-in, a manual gate, a credential, a blocker no category fits, a QA verdict the ladder tried for and could not produce, anything destructive or published. The loop has every word of it.

A session that boots and then says nothing

The ladder above is for a phase that stopped. A session that is still running and has simply gone quiet is a different animal, and the console used to be able to do nothing but put a card up about it: one lane in a measured incident sat silent for seventy minutes and the whole cost of that was one notification an hour earlier.

Now the console does what the operator did, on a clock. At ten minutes of total silence it writes to the session itself (journal: phase.auto-nudged) — the same thing a Steer does, and the only thing that has ever recovered one of these by hand. Five minutes later, if the lane is still silent, it recycles the session (phase.auto-recycled): the child is ended, the phase goes back to pending with its session id kept, and the ordinary drive loop re-boards it as a resume — same conversation, same lock, same lease. One nudge and one recycle per phase, ever: if it wedges again with both spent, the console stops trying and parks the phase with one errand (phase.stall-parked) naming what was already tried, while the run keeps driving its other lanes. Pressing Retry clears the ledger and lets it try once more. A session that answers the nudge and then wedges again still gets its recycle — the bound counts what was actually done, not how many times the lane went quiet.

It only ever fires before a session's first turn — no tool call has ever opened, no turn has ended, nothing has been spent on this attempt, no task list published, nothing committed and the tree clean. That is the one class where ending a process provably loses nothing, and every one of those facts is about this attempt, not the phase's history: a phase that committed last time round is still helped this time. A silent session with any work in it is left alone and stays a card for you. It never touches a frozen or pausing lane either — every lane of a console frozen whole included: those are silent because the console made them so.

Underneath it, and independent of all of the above, every session now carries a first-event bound — a session that produces no output at all for twenty minutes is ended through the normal teardown, even with no console driving it. Twenty and not fifteen so that it lands strictly after the recycle rather than level with it: the two clocks must never race over whether the session is kept. Output that cannot be parsed is logged and counted rather than dropped, so "it said nothing" stays a claim you can trust.

Your turn: what only a person can do

Everything a run asks of a person is one item on one page: #/turn, titled Your turn and lighting Runs. #/approve and #/approve?step=<id> land on it in one hop, a push opens #/turn/<id> (docs/phone.md has the buttons a push carries), and the approval queue, a question, a gate and an errand each draw the same item with Open on Your turn. The Runs home's Your turn group keeps runs and one line linking here, and Your turn (n) in the situation line counts items. The guide's Your turn card is the person's account of the page; this is the reference, and references/turn.md is the session's.

The page, and what an item says

Six sections in a fixed order: Do now, Needs one detail from you, Coming up, Being checked, Done and Handled by the AI. Each item is one card: why only you (one of ten reasons — permission, identity, secret, money, legal, decision, physical, reach, third-party, reserved — held to what its kind allows; an item that names none is given its kind's default, marked inferred), the plan, phase and run it belongs to, what it unblocks, the effort, its guide as steps (a why paragraph, ## Steps, ## If it goes wrong; at most 20 steps and 24 KB; every command copyable as written or behind ! for your own Claude Code session, and never run by the page), how it will be checked (a proof of one type: probe, answer, judgement, attest or grant) and ONE primary action chosen by its kind and state. Filters (plan, run, kind, reason, risk) and the search live in the address; Export downloads the open items as one Markdown document and Print prints it. A guide carries its language: a Persian one is drawn right to left, its commands left to right.

Every source passes ONE door, raiseTurn (server/turn/index.ts): a plan's Human step: bullet, a session's phase-outcome.sh … needs-human --step, a person errand (the same item — its Done — continue checks it), a wall a session met (permission), a missing credential found at boarding (secret-entry, proven by the watch scheme credential:<id> — gh, claude, env:NAME, keychain:SERVICE or file:PATH, read by presence and never by value), an MCP server unreachable under require (mcp-login) and a relayed question no rule answers (a decision that keeps its options). Health, lock, ruling, policy and message rows stay in the bell. A plan step marked auto-open: host opens at launch, on the machine, behind --allow-terminal or --allow-agent — an http or https link only, once the launch door has shown it whole.

RouteWhat
GET /api/turn[?seen=<ISO>]{round, headline, groups, handled, counts, seen}: the round that read it (round.n rises only when a round changed something), the one-sentence headline, the items in five groups (now · decide · upcoming · checking · done — Done holds what settled in the last day), the Handled by the AI rows, newest first, and the counts, with how many were handled since seen (the person's last look, echoed) or, with none, in the last day.
GET /api/turn/:idOne item explained: what it asks, its guide, its attempts and why it is a person's.
GET /api/human-steps[?open=1]The human-step ledger itself.

The moves

Every verb is POST /api/human-steps/<id>/<verb>, with the console header and a same-origin check, recorded under the press's actor. The authority presses are rows of AUTHORITY_ROUTES, so no session's door can make them; ask and evidence carry no authority, and the route refuses an agent's door on them instead (a session raises an item; it never asks or attaches in a person's name).

VerbBodyWhat it does
open{where?, what?, confirm?}Opens the item's link or command. On the machine it needs --allow-terminal or --allow-agent and answers the full URL for confirmation first.
check{note?}I've done this — check: asks for a verdict (below). A body that carries a secret is refused, naming where the value goes instead.
snooze · cannot · dismiss{minutes?} · {reason} · {note?}Hold the next reminder back; I can't (the item becomes an errand carrying the reason); withdraw the step.
answer{option?, note?}Answer a decision — one of its options, a note, or both (Send my answer).
decline{reason}Not doing this, where the item allows it; ends declined (withdrawn stays the console's own word).
ask · evidence{text} · {kind, text | data, mime?, name?}A question about the item, and evidence: a note, an image or a file — 160 KB a piece, six an attempt, screened for secrets, kept 0600 by content hash in turn-evidence/ (swept at 30 days or 64 MB), never pushed or journalled.
override · rewrite{note?}Accept anyway — the owner's verdict, recorded as unverified — and a rewrite: an escalated item is withdrawn and its raiser resumed to send a new version.
grant · deny · convert{scope, rule?, reason?} · …A permission item's Grant, Deny and I'll do it myself (below).

Each answer goes back to every waiting session ONCE, in a sentence the console composes — "The operator answered <option>: ", "The operator declined: . Do not ask again; …" — carrying any question the person asked; an answer that names a ## Decisions key is written to the plan's decisions table through decisions.sh first. The console never takes a secret: a secret-entry item says where the value goes — the keychain item phase-console-<id> on macOS, a 0600 file elsewhere, or the item's own credential: place — and the check finds it there.

The check

I've done this — check gets a verdict: passed, rejected (with exactly what to redo) or needs-info. A command's proof is read at once and a miss says what it read. A proof only words can state is read by a short read-only checking session for that one item — at most 12 turns, $0.50 and five minutes, sonnet at low by default — whose verdict is parsed from its last fenced block and never invented. Settings ▸ Automation holds its three controls: Check what a command cannot (off, such an item is accepted on the person's word and marked unverified), Checking model and effort and Send to the owner after (turnEscalateAfter, three rejections by default).

Each attempt keeps its evidence and its verdict. A rejection tells the person once what to redo (Back to you, the attempt and the miss) and resumes nothing; a pass resumes every waiting session once, saying what was proven and what the check read. After the last allowed rejection the item escalates once and asks how it ends: rewrite the guide (…/rewrite), I can't, or the owner's Accept anyway (…/override), the one route that writes a verdict. No session and no agent marks its own item passed: a judgement passes only on what the person sent for that attempt, and an attest, an override or a rewrite counts only through a person's door. A judgement check asked for on a frozen console starts no session: the item says the check could not run (turn.check-frozen). phase_console_turn_checks_total and phase_console_turn_check_usd_total count the checks (docs/metrics.md).

Permission items, grants and the never list

Every wall a session meets is recorded on its lane — a deny rule, each guard of the console's own hook, and the CLI's own refusal (a tool outside the allow list, an MCP tool not granted, the CLI's copy of a deny rule) — and a refused landing push is a capability that is off. blocked --needs permission citing one raises ONE permission item, "raised because the AI lacks permission …", with the command, the phase, why it was needed, the wall and its risk tier; one citing nothing recorded is refused at the door (exit 4, "nothing refused this — run it"). The approval broker's ask and the widen rung's card are this same item. Besides Grant it offers I'll do it myself (it becomes your own act, and I've done this resumes the session) and Deny (the session is told not to retry and to find another way).

A grant is ONE press at one of five scopes (GRANT_SCOPES):

ScopeReachesEnds
call — This callthis one callspent on use — or, unspent, when its phase settles, at most 24 hours after it was given
phase — This phasethe lane's rule, in this phasewhen the phase settles; at most 24 hours
plan — This planthe plan's policy file, ~/.config/phase-console/plans/<instance>/<slug>.jsonwhen revoked
repository — This repositoryevery plan of this console, ~/.config/phase-console/repositories/<instance>.json — a layer between the plan's file and the machine'swhen revoked
always — Alwaysevery plan on this machine, ~/.config/phase-console/autopilot.jsonwhen revoked

Risk comes from GRANT_RISK, one table total over wall × rule family × scope. Low (a call or a phase on an ask, an allow list or an MCP wall) and medium (a plan or a repository on those) are one press. High — a profile raise, a capability, any other deny-wall rule, and every always — shows what it reaches (a 400 {rule, blast} replaces the card's preview with the server's own blast radius), asks for the rule typed back, and on a console with an owner key needs the key touched inside the last five minutes; with no key the card says so and the typed rule alone grants it. A capability is granted at the machine only — the unit verb, then a restart when idle — never from a phone. The never list offers no grant through any door at any scope: a forced or deleting push, the host family (sudo, shutdown, reboot, mkfs, dd), a protected path, a secret's value, the console's own guard, a missing credential, a sandbox or network wall, and Claude Code's own classifier. The item says why and gives the manual path as a guide; for the classifier it names the rule you could add to your own settings, which the console never writes.

The server applies a grant (server/permissions/grants.ts). Below plan scope its hook lets exactly that lane, rule and (for a call) call through — never a sibling lane, never after the phase settled, never a neighbouring rule — and the run's settings carry the rule lowered for that run only, raised again once it ends: for that long the CLI's own list does not hold it with the console dead. At plan scope and wider the grant is a policy edit, and its row names the file, the list and the rule it changed. The waiting session resumes by itself — "The operator granted <rule> for until — run it again" — a held hook call is answered at no cost, and an open item a later grant covers withdraws itself. The approval card's Allow with remember, the widen card's Allow and a standing card's one-time allow are grants now: nothing else writes a rule on a person's behalf. Under a lifted push wall a push passes only by an allow-list: an abbreviated flag (--force-w), configuration before the verb (-c), a forcing refspec, an option that runs a program (--receive-pack), arguments fed by xargs, and a command, verb or option the shell has yet to expand all read as a forced push.

Every grant is a row of grants.ndjson (0600, append-only, a retention sink): who granted it through which door, the item, the wall, the rule, the scope, the end and exactly what it changed, then an end line when it is spent, expires or is revoked. It is journalled (policy.grant-applied, policy.grant-ended) and announced in the push category Permission granted (granted).

RouteWhat
GET /api/permissions/grantsEvery grant this console holds, with the number live.
POST /api/permissions/grants/:id/revokeEnd one live grant ({reason?}): exactly what its row says it changed is undone.
POST /api/permissions/grants/revoke-allEnd every live grant.

A revoke takes authority away, so no owner key is needed: the door table lets local, device and the owner decline. Settings ▸ Permissions ▸ Grants lists every grant with Revoke and Revoke all, and phase-console grants does the same from a terminal.

The owner door, and the owner key

Every request has ONE door, decided by what it can prove and never by what its body says (PRESS_DOORS in viewer/shared/door-model.js, read by server/owner/door.ts):

DoorProved by
owneran owner key verified in this browser — an owner session
devicea login the --remote proxy verified, or a signed lock-screen action
locala loopback request with the console header and nothing more — a script, the CLI, a browser with no key, any process
sessiona run token

Two more doors mark the console's own in-process writes and are no request's: checker, a checking session writing the verdict of its own item, and console, its own clocks — probes, withdrawals, timers.

DOOR_MAY says which door may press which authority verb (grant, answer, decline, attest, override, policy-widen, profile-raise, gate-approve, plan-approve, capability, owner-key, trust) at which risk, in two modes. With no owner key — every console until a key is enrolled, ownerDoor: 'unenrolled' in GET /api/state — local and device press what they could before, and an agent's door presses only what the plan's manifest already allows. With a key, owner may press everything grantable; device makes low and medium grants, answers, gate and plan approvals and declines; local declines; a session makes none; and a press through a door that may not make it is a request the owner confirms. AUTHORITY_ROUTES names every authority route and is held both ways: a route that presses an authority method without a row fails the suite, naming its line. A manual gate's person test is a door — owner or device, and local on a console with no key — not a User-Agent.

The owner key is a passkey, WebAuthn verified by hand with node:crypto, no new dependency: attestation none, ES256 or EdDSA, user verification required, a single-use challenge good for five minutes, the origin and relying party taken from the request's own host (localhost, or an https host the console serves; an IP address is refused with the hint to open localhost), and a signature counter that never goes back. Its routes are in both editions and behind no capability flag:

RouteWhat
GET /api/ownerThe door's state (unenrolled · enrolled · unlocked), the keys with no key material, this browser's session, the requests waiting and the relying party this host names.
POST /api/owner/enroll/link · …/enroll/begin · …/enroll/finishThe registration ceremony. The first key only at the machine, from the one-time link phase-console owner enroll prints (good for ten minutes; it opens Settings ▸ Permissions at ?enrol=<token>); a later key only inside an owner session with the key touched inside five minutes, or through a link the owner minted for another device.
POST /api/owner/assert/begin · …/assert/finishSign in with a key: an owner session (twelve idle hours; a 256-bit HttpOnly, SameSite=Strict cookie, __Host- on https, of which only the hash is kept) or a fresh touch for a high-risk press (valid five minutes).
POST /api/owner/lockEnd this browser's owner session — or, from the machine, every one.
DELETE /api/owner/keys/:idRemove a key, inside a fresh owner session; it ends that key's sessions.
POST /api/owner/requests/:id/confirm · …/refuseThe owner's one press on a request another door asked for. A confirmed request is pressed again, through the owner's door, with the confirmer's own touch if it is high risk.

Every key change is journalled, pushed to every subscribed device and kept in the bell for a week. What the plan's manifest already allows still executes with a key present. The keys (owner-doors.json), the sessions (owner-sessions.json) and the requests (owner-requests.json) are 0600 and hold no secret. The residual risk, in full: a process running as you that deliberately rewrites the console's own files can forge anything below the owner key, and can replace the key registry itself; the console walls the paths a session takes and makes every grant visible, and it is not a boundary against your own account.

Rounds, and what the AI handled

Your turn is kept up to date by the console itself. A round — one pass of the human-step clock (a minute) and of every run's journal line (debounced two seconds) — ends the grants that ran out, sends the reminders that are due, withdraws an item nobody needs and expires a window that closed, withdraws an item a live grant now covers and resumes its waiters, escalates an item returned turnEscalateAfter times that never escalated, brings an upcoming act due with its ONE push, and reads the open proofs the console owns (credential:<id> included, on its back-off), proving one that landed. Its proof pass never runs a cmd: or unit: proof, and what it resumes goes through the console's own door, never a person's, and only where --allow-run allows it. A round that changed the turn raises round.n and sends ONE server-sent event, turn; a quiet round changes nothing. The headline is composed by rules — how many need you now and for how long the oldest has waited, how many are being checked, how many are coming up, how many were handled since you last looked — never by a model.

Handled by the AI reads handled.ndjson (a retention sink): what the guard refused to ask, what the rule table allowed (ONE row per rule per phase, with a count), relay answers by rule and the ladder's recoveries, each linked to the journal line that says it. A session adds its own with phase-outcome.sh <slug> <N> handled --what … [--note …] [--link …]; those rows land in handled-sessions.ndjson beside the ledger, and every line of it reads as the session's whatever it claims, so a session can never speak as the guard or the rule table. phase_console_turn_rounds_total and phase_console_turn_handled_total count them.

The supervisor's part, and the fleet's one list

A supervisor escalation is an item: an operator-act, birth supervisor, with a guide and the proof its detection names (else the supervisor's own evidence, read again), so it is proven and its waiters resume when it lands. The supervisor's chat is the supervisor door, a message token is session too, and the login the fleet supervisor asserts for a paired phone is device. GET /api/fleet/turn merges every console's turn, each item's endpoints under /c/<id>/, so a press lands on the console that raised it. An issue draft is no item: GET /api/turn names how many wait, with a link to Repo ▸ Issues (issues in its answer). The supervisor and its chat has the remedy rows and the chat's tools.

Stopping things, at three sizes

Three surfaces carry the same verbs, scoped differently. The run controls act on the whole run: Pause after this phase (boundary), Freeze now (SIGSTOP every session, reversible), Stop now (SIGINT to each session first, so its turn closes and writes its result, then SIGTERM to its process group after a 5 s grace, with an awaited SIGKILL behind that — viewer/server/runner/signals.ts; each phase's status records interrupted — its lifecycle state folds that to failed, as it folds every stop, so the two words are two axes and never a red verification). Each session tab — on the autopilot page, the Runs page's lanes, and the session console's own toolbar — carries Freeze/ Continue and Stop for that one session: the rest of the run keeps scheduling, a stopped phase keeps its session id for Retry, and a queued phase's Stop takes it out of the admission line before anything spawns. The run cards on the Runs board carry the run-level Freeze/Continue and Stop, so a live run is never a card you can only link away from. None of these touch the consecutive-failure budget. Only a person's press resets it — Start, Continue or Retry; every automatic door (the convergence loop, a watch landing, a boot re-adoption, the healer's rungs) carries the streak forward, and a run that stopped on a spent streak is refused the loop's own relaunch (run.relaunch-refused) until somebody presses. The fourth and largest size is the whole console at once — the next section.

The budget, and what may spend it. The counter means this plan keeps failing — it is the run's one "stop, something is wrong with the work" bound, which is why failure-streak is a halt only a person's press relaunches. So since 5.2.0 exactly one function raises it (chargeFailure), and two things never do: a refused credential and a connectivity stop. Neither is a claim about the plan — the run is being refused entry, or the machine cannot reach the API at all — and letting them spend the budget both destroyed the signal and converted a transient fault into a second, independent press-only halt. (Measured: one network event charged three failures in 39 seconds and left a run at 3 of a maximum 2.) The counter can also no longer exceed its maximum, and a second lane meeting a wall the run has already halted on charges nothing and raises no second errand. What it refused to count is journalled (run.failure-streak-held), because a number that quietly stopped moving is how "3 of 2" went unexplained for a day.

What the streak counts: failed PHASES, not endings (control-tower phase 45). The streak is the ordered set of distinct phases whose ending was a merit failure since the last phase that settled done — MERIT_FAILURE_CAUSES in shared/run-lifecycle.js: a §Verification whose final attempt is red (verify-red), the plan left failing its lint (plan-lint), a person marking the manual checks failed (checks-failed), a session that ended with no handoff and nothing on disk (no-handoff), a phase declared blocked (declared-blocked), and a session whose every attempt failed (crash). The number on the card is that set's size, and the halt names it in order — "2 phases failed in a row: phase 3, then phase 5". Each charge is journalled run.failure-charged with the set. Never charged:

  • a command rescued by its retry — a command's verdict is its last attempt, and one function (verificationVerdict) decides both the verification's ok and the halt, so a verification that reads green can no longer halt its phase verify-failed (every verify-failed halt of one measured week was that contradiction);
  • a second ending of a phase already in the set — the same phase blocked twice is one failure;
  • a spent wait budget — a budget event, not a failure: the phase waits on its refs with a budgets errand (see session-budget.md), and neither fails nor spends a rung;
  • an ending the board contradicts — a red verdict over a handoff the board already reads complete settles done with the red on the record, and a phase the board later closes leaves the set;
  • no handoff with work on disk (no-handoff-worked) and the network (connectivity) — the first is unfinished paperwork the ladder's work-in-progress rungs answer, the second the weather;
  • a declared block the watch clock can end (declared-wait) — a blocked declaration naming a ref the console polls is a wait: the runner parks the phase waiting on it (its poll-park, on the wait budget and window a waiting-external gets), and its landing resumes the phase's own session;
  • a second charge on one root cause — a charge names what it is blamed on (the blamed commit, else the refs a block watched), and a sibling stopped by the same cause is held, not counted: the halt says each counted phase's cause, "phase 5 (on commit 1c8164e9)".

Recover & continue over a superseded stop. A run halted on a phase the board now shows done — the commonest failure-streak shape, since its anchor is a phase this run adjudicated and reconcile rightly leaves such a record alone — is cleared and continued by the one press (run.plan-recover {step: 'superseded'}), rather than answered "nothing to launch" over a halted run with ready phases.

Clearing it. POST /api/run/<slug>/clear-streak zeroes the count and nothing else — its own verb, because when an outage spends the budget there is no phase to retry and Retry was the only thing that cleared it. The stopped run's "Why this is stopped" card draws the streak as n of max beside the control, and says so when every registered account is unusable (halt.accounts), so "switch to another account" is never advice you cannot take. Answers 409 on a finished run: a settled run's record is not editable. Journalled run.failure-streak-reset {was, by, via}.

Raising a budget (control-tower phase 14, #40). Every budget that can stop work — a phase's wait, a phase's or the run's dollars, the recovery ladder's cap, the failure streak — announces under the budget notification category at 80% (phase.budget-approaching, once per budget per attempt) and when it is spent, and the card's first line says budget with the arithmetic (60m wait budget · 39.7m accrued · 20.3m left · asked for 90m) rather than the sentence of whatever it stopped. POST /api/run/<slug>/raise-budget {budget, phase?, add? | to?, scope?} raises it in one press: wait writes the plan where the budget was declared — the phase's Waits on: max, or with scope: "plan" the plan's Wait budget: — through scripts/wait-budget.sh, so it needs --allow-writes (403 otherwise); phase-usd, run-usd and ladder patch the run's own setting (phaseBudgetUsd, runBudgetUsd, ladderPerRunRungs — or the cap errand's ladderPerPhaseRungs). Either way it journals run.budget-raised {budget, was, now, by, via: raise-budget}, answers the hold the budget left (the spent-wait stamp, which re-arms the watch; the budget errand; a budget halt) and retries — the phase it held, or the run it halted. The streak is never raised: this verb answers 400 naming clear-streak. The card offers +30m, +60m and any amount for a wait, a dollar step for a cost cap and rungs for a cap, beside Clear the streak.

Freezing the whole console

The fourth size, and the one to reach for when you are stepping away or something is going wrong faster than you can read it. Freeze all (Settings ▸ Automation, and the orchestration board's header on #/runs) stops every running session where it stands and starts nothing new — no queued phase, no elapsed wait, no recovery, no convergence pass. Thaw all puts the whole console back exactly where it was.

Four things are worth knowing, because each is the opposite of what a stop button usually does:

  • Nothing is lost, and nothing becomes a checkpoint. A frozen session is SIGSTOPped, so it thaws mid-token in the same process. Unlike a per-lane Freeze now, a whole-console freeze carries no fifteen-minute conversion: it is a standing freeze, so a console left frozen over a weekend is still exactly frozen on Monday.
  • No clock is moved. Every timer keeps running and simply finds the console frozen when its moment comes. A wait whose window passed during the freeze fires once, at the thaw; a queued entry stays queued, names the freeze as what it waits on, and never ages into a reservation.
  • One case needs a press. A run whose phase happened to be between its session ending and its closeout when you froze settles paused and attributed to you — and a run a person paused is deliberately one the console will not resume by itself. Thaw-all wakes everything and unholds the queue; that run wants Continue. Nothing is lost (its phase is pending and keeps the session id, so Continue resumes rather than re-runs), but it is the one place "exactly where it was" means "plus one press".
  • It survives a restart. The freeze is a marker file in the console's instance state, so a console that dies — or a laptop that closes — does not quietly hand its work back. A console that boots under one re-adopts nothing and spawns nothing.
  • Your own hands still work. Freeze/Thaw and Stop on a single run, and Recover & continue, are operator acts rather than auto-starts, so they go through. What stops is the console acting by itself.

The gate is one predicate, consulted by every mechanism that could begin work, and the list of those mechanisms is pinned in viewer/test/fleet-freeze.test.ts: a new one cannot be added without declaring where its freeze gate lives.

POST /api/fleet/freeze and /api/fleet/thaw (both --allow-run) are the endpoints — each answers 409 with a reason when there is nothing to do, so pressing Freeze twice never re-stamps the moment you froze it. GET /api/fleet ({fleet, allowRun}) and /api/state's fleet field carry the state, which is what the app-wide banner renders on every page. The freeze itself is freeze.json ({at, by}) in the instance's state directory.

The wire says fleet; the words say console. FLEET_TIERS in viewer/shared/fleet-model.js names three tiers, narrowest first: run (one plan's autopilot), console (one process, one repository, every run it drives) and fleet (every console of one person on one machine). This section is the console tier. Its routes and state field keep their older spelling so a client in the field keeps working — state.fleet, /api/fleet/freeze, /api/fleet/thaw (LEGACY_CONSOLE_TIER_NAMES) — and state.fleet is {frozen, at, by}, where frozen is this console's own freeze and nothing else.

Beside those three, state.fleet carries the fleet tier: url and reachable (where the fleet supervisor is, and whether it answers) and hold (the machine hold it wrote, or null). A machine hold sits beside frozen and is never folded into it: it holds every automatic start on the machine and leaves live sessions running, so Freeze all still has work to stop under one and goes through, while Thaw all on a console held only by the machine is refused with 409, naming the supervisor as the place to release it (phase-console fleet release). See The fleet supervisor.

Freeze, then stop is two presses, not one verb: Freeze all, then Shut down (next section). The freeze marker is instance state, so the console that comes back is still frozen and starts nothing.

Restarting the console — on its latest version

Restart (Settings ▸ This instance, phase-console restart, agent.sh restart, the Desktop launcher) brings the console back on the latest version of its copy, not on whatever is on disk (2026-09-18). The press is answered at once (POST /api/restart → 202 {updating: true}), and then:

  1. deploy/self-update.sh brings the copy to the newest commit of the branch it tracks — fast-forward only, so local edits, commits of its own, a detached HEAD or no upstream leave it where it is (said, not failed); npm ci only when package.json or the lockfile moved; the client built and gated in a staging directory (npm run verify:dist -- --keep) and swapped in, so the live client is never half-built;
  2. while it builds, every automatic start waits on the restart hold (fleetHold() answers scope: 'restart' — a machine hold's kind: nothing running is stopped, the freeze banner stays dark), and every open tab shows a banner;
  3. the process exits only onto a copy the updater vouched for — the new version, or, when the update failed and rolled back (restart.update-failed), the one it was running. An update that could not put the copy back (broken), another update on the same copy (busy) or a run started by hand while it built stops the restart instead (restart.update-held), and the Restart card says why.

A run in flight still refuses the plain press — a restart would cut its session in half. Restart when the live sessions finish (POST /api/restart {whenIdle: true}, restart --when-idle) waits instead, and since control-tower phase 48 it waits for every LANE a runner holds — a live session, a §Verification the console itself is running, or the settling after either (closeout, the outcome's paperwork, a landing) — not only for session processes: a phase that had just started its verification once read as idle and was cut with none of its commands run. A pending approval card counts as live too. The restart hold keeps new phases of the plans those lanes touch — every plan whose scope meets theirs — from boarding, and says so on each held run's own journal (run.restart-held, then run.restart-released); every other plan boards as usual. The card and restart.waiting say when the wait expects to end — the lanes' own estimates: a session's size, a verification's timeout (restart.expected-end, again whenever the lane set changes) — and its cap, max(2 h, expected end + 30 min) from the press. The moment none is left, or at the cap, the console updates and restarts through the drain: each run checkpoints and carries on from the new process (restart.drained, or restart.drain-capped over lanes still live, which the next console adopts). A §Verification a restart cuts is owed, never waived — the next drive re-runs it before reconcile may close the phase on the board's word (phase.reverify-after-restart). A restart asked from inside a run's own session (the CLI sends $PE_SESSION_ID) is that session's act, on its run's journal (run.restart-requested). Cancel ({cancel: true}) calls it off and the held phases board at once (restart.cancelled). From a shell, restart --force insists through launchd (the old kickstart -k), and restart --no-update is the plain restart. A console that does not answer is restarted by its supervisor as it is; update updates its copy first. PHASE_CONSOLE_SELF_UPDATE=0 in a console's environment turns the update off for that copy — a copy somebody develops in; the test sandbox sets it for every console a test starts, so no test can move or rebuild the repository under test. The page that pressed reloads once the console answers with a different bootedAt — its process identity on /api/state — rather than on a timer.

A restart also brings the console back on the node arguments this machine's profile asks for (nodeArgsFor — the heap size; control-tower phase 48). An exit alone comes back on whatever the launchd unit said when it was installed, so the restart first reads the unit's ProgramArguments: when they drift from the profile, or this process runs an older definition than the file, it re-renders the array in place and reloads the job — bootout, wait, bootstrap, the installer's own sequence — rather than exiting (restart.unit-reload). The restart an update ends in judges the unit by what the updated copy renders, so node arguments a release ships arrive with it. A unit that cannot be re-rendered refuses the press before anything is spent, naming the agent.sh install line — with every flag the console runs with — that re-renders it (restart.unit-refused); --force restarts it as it is. agent.sh restart does the same when it restarts a console itself. /api/state carries execArgv and unitArgs, and phase-console doctor has a node row for both. The pty broker outlives every restart, so it makes node-pty's spawn-helper executable again before each terminal it spawns — every npm ci leaves it at 0644 — the updater does the same after each install, and doctor's pty row flags a helper nothing may execute.

Shutting the console down

Settings ▸ This instance carries the off switch in two strengths (SHUTDOWN_MODES):

PressmodeWhat it does
Shut downexit (the default)The process drains and exits. Under a supervisor that keeps the job alive (launchd KeepAlive, systemd Restart=) it comes straight back, so this stops the work — every run checkpoints and resumes — rather than the console.
Stay off…unloadThe unit is unloaded and disabled, and a stop marker is written before either step, so neither the next login nor a hand-started process picks the automation back up until somebody clears it. It needs a unit this process can name; with none there is nothing to unload, and exit already stops it.

What a press achieves is one of four words (SHUTDOWN_DURABILITIES), said by the dialog before the press and on the shutdown.requested log line after it: returns — a supervisor brings it straight back; until-login — nothing brings it back now, and the next login starts the unit again; stays-off — nothing supervises it, so nothing starts it again; disabled — unload's word, the unit disabled and the stop marker holding the boot.

The dialog is an inventory. Before it opens, the app reads GET /api/shutdown: both plans (modes.exit, modes.unload), the command that undoes each (restartHint, unloadHint), any bootHold, and the inventory — what the exit would stop, computed from work rather than from counted terminals:

  • lanes — every lane a live runner holds, with its pid and session;
  • clocks — every in-process clock the exit discards, each with its moment and its source (SHUTDOWN_CLOCK_SOURCES: wait-resume · freeze-escalation · mcp-require · outcome-inbox · session-inbox · converge), every one re-armed from disk by the next boot;
  • runs — the runs the next boot picks back up, each with its wait clock;
  • liveSessions — the sessions the presence registry shows live;
  • pendingApprovals — the cards still waiting on a person;
  • inboxDepth — {sessions, outcomes}: presence drops and unsupervised declarations not yet read.

empty says the whole inventory is empty, and soonestClock names the work clock that matters first. Confirming in the dialog acknowledges the list it showed.

The route refuses a press that has not said what it means. POST /api/shutdown carries the cross-site guard and nothing else — not --allow-run, because a read-only console must still be able to stop — and takes {confirm: true, mode?: "exit" or "unload", acknowledge?: true}. A refusal answers with the inventory it is about:

StatusneedsWhen
400—no confirm: true, so a stray POST cannot end the console
400modea mode that is neither exit nor unload
409unloadunload with no unit to unload, or a stop marker that could not be written — a "stay off" that would not hold is not carried out weaker than it was named
409acknowledgeunload without acknowledge: true, always, the reason naming the command that undoes it
409acknowledgeany press without acknowledge: true over a non-empty inventory — the reason is the inventory, soonest work clock first — or over an inventory that could not be read

Going down is announced, and the announcement is waited for. A press that goes through logs shutdown.requested with the request's actor, the mode, the durability and the inventory's digest, then sends a health announcement — Phase Console is shutting down, who pressed it and what it was holding — and the drain holds for its delivery for up to 5 s (SHUTDOWN_ANNOUNCE_WAIT_MS). When nothing reports a delivery, the record gets a skipped delivery row saying why, never an empty list.

Every abandoned run is told why. Why the process went away is one of SHUTDOWN_INTENTS: shutdown (a Shut down press), restart (a Restart press) or signal (a SIGTERM or SIGINT no request explains — a logout, a kill, launchd stopping the job). The shutdown.begin and exit log lines carry it, and so do run.shutdown-child and run.console-shutdown: a run a live runner drives writes its own from its checkpoint, and every other run the inventory lists — queued, waiting, paused or parked on a clock — gets one on its own journal naming the clock the exit discards.

The stop marker, and who clears it

A mode: unload press writes stopped-by-console.json in the instance's state directory before anything is unloaded ({at, by, via, origin, remoteUser, mode, durability, label, resurrect}). A console that boots under it holds its automation — BOOT_HOLD_KINDS stopped: nothing is re-adopted, nothing converges, no sweep and no watch clock run, and boot.hold says so once. Your own presses are never held. The other hold, autostart-off, is the machine profile saying this console does not start its work unattended (below).

The hold is GET /api/automation/hold ({bootHold}); /api/state and GET /api/shutdown carry the same bootHold. Settings ▸ This instance shows it as a banner with one press — Clear the stop and resume for stopped, Release it for this boot for autostart-off — which is POST /api/automation/hold/release (--allow-run, because releasing starts work; 409 when nothing holds): the marker is removed and the boot pass the hold skipped runs now, the re-adoption and then the convergence loop. The profile is not edited, so an autostart: false still holds the next boot. A marker removed from outside, under a console that is holding for it, counts the same: that console runs its held boot pass itself (boot.hold-released, how: marker-removed).

From a shell with the agent installed, phase-console start (through agent.sh start) removes the marker and enables the unit again before it starts the console.

Whether a login starts it

The machine profile's instances.<id>.autostart — true, false or once — is the start policy a login and a boot read (The machine profile). false holds this console's automation at every boot (the autostart-off hold) until it is released for that boot. once is spent by the boot it grants: the entry becomes false and the console's own unit is disabled, so the next login does not start it again (boot.autostart-once).

Set it with phase-console autostart [instance] true|false|once (with no word it prints the one in force); agent.sh install bakes it into the unit's RunAtLoad.

Two plans in one repository, at the same time

The scheduler's rule has always been never two live sessions whose scopes intersect, and the only way it could keep that promise was to make the second run wait. Isolation is the other way to keep it: give the run a checkout of its own, and the two are no longer in one working tree.

Turn it on for a run with Give this run its own checkout on the launch form. It rides the work-branch git strategy — a run with no branch of its own has nothing to check out — and on a live run the switch only turns off: a run's commits are on the branch in the checkout it started in, and there is no honest moment to move them.

Asked-for and got are two different facts, and the console keeps them apart. A run that asks to isolate can be refused — the scope names no repository or an uninitialized submodule (scope-unmapped), the disk cannot take another tree, the cap is reached. A superproject itself is not one of those any more: since 2026-08-28 the run takes a mirror — one linked worktree per scoped sub-repository, one mirror per RUN rather than per lane, so per-LANE worktrees under a superproject share the run's mirror, serialized per scope; and since 2026-09-05 the mirror mounts the superproject's own ROOT as well, as its first tree with the initialized submodules under it, so a scope meaning the root no longer takes the whole run's isolation with it (the root-scoped refusal that did is no longer produced, and is kept only so stored journal lines still render). Only the plain non-mirror worktree of a repo with submodules still refuses, as has-submodules, because git worktree add there gives EMPTY submodule directories. The console still never moves a superproject's recorded gitlink shas: the one-tree settle (merge-queue) refuses a multi-repo run by name (run.settle-unsupported); integration settles every mount into its own staging tree, deepest first, and raises an errand for the pointer bump when a submodule's staging took a merge commit; and pr opens one pull request per mounted repository that has commits. Every refusal degrades to the shared checkout with queue semantics — exactly the behaviour that existed before the feature — and says which impossibility it hit, in the run journal (run.isolation, run.isolation-kept) and on the run page. Nothing fails.

Four settings under Settings ▸ Automation govern the console's own trees, and they are shown only while isolation is on: Worktrees at once (a cap, shipped at 3 — each is a full checkout on disk), a setup command run once inside a fresh tree (npm ci, a symlink, nothing), whether to copy the source checkout's ignored .env files into it — off by default, because copying secrets into a second directory is a decision, not a discovery — and whether the console may take the run branch back from a clean checkout.

The console may switch your clean checkout off pe/<slug> (Settings ▸ Automation ▸ Take the run branch back from a clean checkout, shipped ON). The wedge it ends is one the console itself creates: the work-branch strategy tells every session to check pe/<slug> out, so your own checkout ends up holding it — and git allows a branch exactly one working tree, so from then on every run of that plan meets branch-in-use and silently shares that tree. With the setting on, a checkout sitting on the run branch with nothing uncommitted in it is switched to the default branch and the run takes its own; the journal says which tree moved and where to (run.isolation-reclaimed), and the isolation preflight says "will reclaim <path>" before you launch. A checkout holding anything — a tracked edit or a file nothing ever added — is never touched: the run refuses branch-in-use and names the files that stopped it. No branch is deleted and no ref moves; the branch is exactly where it was, with one fewer working tree on it. The reclaim also consults the lock table (read off the lock files themselves, so a claim made a second ago counts): a checkout a live lock names in its worktree= line — any plan's, unexpired, its session not ended — is never moved, and a live lock that names no tree (the ordinary hand claim, claim N --scope … --git with no --here) holds every checkout of the repository its scope touches, because a claim that never said where its work rides may be riding this one. Either way the refusal names the holder. (Until console-parallel-repaint P1 only dirtiness protected a live session's tree, which held most of the time.) Never turns it off.

Post-merge phases board a detached mirror. A run whose pull request has merged and whose pe/<slug> has been deleted still has phases to drive — and re-creating that branch would re-open work the merge just closed. Such a run boards a checkout detached at the default branch's head instead: it owns no ref, so any number of them may stand beside each other and beside whoever holds the branch. Its lock is qualified branch=detached@<sha12>, which contends with nobody holding a real ref, and with another detached claim only when the two name the same working tree — two runs detached at the same commit in two console-managed trees run side by side (they were serialised before console-parallel-repaint P1). A plan can ask for it deliberately with - **Checkout:** main on a phase (main, master or the word default; anything else is documentation the console does not act on). The journal line is run.isolation-detached.

Merged pe/* branches are deleted after a pr settle (Settings ▸ Automation ▸ Delete merged run branches, shipped ON). Once the trunk contains a run's commits the branch is a name for something that already happened, and a console that drives thirty runs otherwise leaves thirty of them behind. Evidence, not assumption: the deletion is always git branch -d, git's own refusal to delete a branch whose commits are reachable from nothing else, so a squash-merge the local trunk has not fetched simply reads as unmerged and the branch survives until the next drive. -D does not exist anywhere in this codebase. A branch still checked out somewhere is kept and named (run.branches-pruned reports what went).

A worktree this console did not make is reported, never removed. Every boot and every drive sweeps the repository's registrations: one whose directory is gone is pruned (it was holding a branch for nothing), and a live hand-made checkout on a pe/* branch is named in the journal (run.worktrees-unmanaged) and left exactly as it is — it may hold work, and every sweep here keeps a checkout that does. A checkout on your own feature branch is none of the console's business and is not mentioned.

Lock leases default to 2 h; the runner claims 90 min. A lapsed lease is takeable by anyone — that is the cooperative design — so the number has to outlast the work it protects, and 30 minutes was shorter than a phase. The console's own sessions never noticed, because the runner refreshes on a timer, which is exactly why the wrong default survived: it only ever hurt a session driven by hand, which has nothing refreshing anything. The runner still states its own (--lease 5400 — RUNNER_LEASE_S, a literal in seconds: 90 minutes, nine times its 10-minute refresh cadence, so eight ticks may be missed before a claim it is actively holding can lapse).

What two live branches are doing to each other is measured, not guessed. The run page's Git card carries ahead/behind against the base, the files the branch has changed, the tree's disk footprint, and a radar verdict for every pair of live branches — clean (no common file), overlap (common files, still merges), conflicted (a real merge conflict is already sitting there) and unknown (the probe could not answer, which is deliberately not clean). The verdicts come from git merge-tree; the whole view arrives on one run:git event, and phase_console_branch_conflicted_files (metrics) is the same number for a scraper. A pair that turns conflicted mints one inbox item whose Serialize action patches the other run back to queue — one press, and the overlap is gone.

A branch has to end up somewhere, and you choose where — When the plan completes, on the same form, for any work-branch run whether or not it isolated:

StrategyWhat happens when the run finishes
Pull request (default)Push the branch and open a PR — the only ending with a person reading the diff
Merge queueOne more session rebases on whatever landed while the run drove, re-runs the plan's §End-to-end verification, and pushes only if that passes
IntegrationThe console merges the branch into its own staging checkout (pe/integration, one per console) and stops — no remote touched, no session spent
KeepNothing. The branch and its checkout stay exactly where they are

Only the two that end at a remote may open the push carve-out; integration merges locally and keep does nothing, so neither gets it. A clean tree is removed when the run settles — unless the retention setting says otherwise — and a dirty one is kept and journalled, because uncommitted work is never the console's to throw away.

A live tree says so, and that is enforced by git and not by us. Every checkout the console holds carries a git worktree lock while it is live, with a reason naming the run — phase-console lane <slug> p<N> <runId> <ISO>. That is not decoration: git worktree prune skips a locked registration and git worktree remove refuses a locked tree, so the belt holds against this console's sweeps, a second console's, and a hand typed in the wrong terminal. Two consequences worth knowing before you meet them:

  • To remove a console tree by hand, unlock it first — git worktree unlock <path>, then remove. git refuses otherwise (it wants --force twice), and the refusal is the feature working. Every path the console takes unlocks first by itself, so a run that needs its tree back still gets it.
  • A lock the console did not write is never taken. Your own git worktree lock --reason "resolving this by hand" is a sentence addressed to the sweeps, and they obey it: the tree is kept and named once (run.worktree-locked-foreign). The same holds for the staging tree — a settle that finds pe/integration locked by a person merges nothing and says so, rather than merging into a tree somebody is standing in.

Each phase lands by the plan's word the moment it settles, and every step survives a restart. The table above is what the finished run's branch becomes; the plan's **Landing:** line (or - **Land:** on one phase) is what each phase's branch becomes the moment that phase is done, and the landing engine acts on it without a person. hold records held in the ledger (docs/handoffs/<slug>/landing.md, written only through phase-landing.sh) and moves nothing. integrate merges the phase's branch into this console's staging tree for that repository (pe/integration, under the worktree lock) and records integrated; a staging tree locked by somebody else — a person resolving by hand, another console — parks the landing (run.staging-locked) and it is retried on the next drive. pr has the console push the phase's own branch (--allow-publish, below), then boards a landing session in the phase's checkout whose only job is gh pr create and the row — phase-landing.sh <slug> <N> pr-open --repo '<key>' --ref <branch> --pr <url> — which the console verifies against gh pr view, refusing one whose head is not the branch; the run's watch clock then polls the pull request, MERGED records pr-merged --sha <merge commit> and clears every dependent gated Gate-check: pr-merged N, and CLOSED records failed and raises ONE errand. trunk is pr plus gh pr merge --merge in the same session — never squash, never rebase, never --delete-branch — recorded landed --sha. The landing session never pushes: the push was the console's act and the deny wall still refuses it, and its two acts are pinned asks under every permission profile, answerable without a person only by the plan's permission.destructive row. Nothing here is remembered as done — each step is re-derived from facts (a ref that exists, a remote-tracking ref equal to the branch, a ledger row, gh pr view), so a console restarted mid-landing resumes from wherever the facts say it is. A lane that will not merge into the run branch follows the plan's **Conflicts:** word: halt (the default — the phase halts worktree-merge exactly as before), park (the phase stays done, its landing reads conflict, ONE errand names the other lane and the files, and the run drives on — halt kind landing-conflict) or rebase-session (the phase's own session is resumed ONCE with a numbered instruction to merge the run branch into its lane, resolve, re-verify and commit — never rebase, never push — and the lane is landed again; a second conflict parks). A STRUCTURAL conflict — both lanes added the same path, one deleted what the other edited, a submodule pointer both moved — halts at once whatever the word, with the kinds named. Under a mirror of a superproject each mounted repository lands separately, deepest mount first and the root last, and under **Gitlink:** bump the submodule's landing session also commits the superproject's pointer bump in the root mount (chore(gitlink): <path> → <sha12> (<slug> p<N>)) so the root's own landing carries it — the console never moves a gitlink itself. pr or trunk on a phase that shared your checkout — no lane, no branch the console owns — degrades to hold (phase.landing-degraded, F28 land-needs-lane); and the run's own pr settle opens a run-branch pull request only for commits no phase pull request already carries, recording why it opened none when every phase landed by its own.

The console pushes only under --allow-publish, and only a branch of its own. The eighth capability flag, off by default, lets a landing push a finished phase's pe/* branch — pe/<slug>-p<N> for a lane, pe/<slug> for an isolated or mirror run — to origin, through the one seam that may ever reach a remote (pushRef in runner/worktree.ts) with one frozen argv: git push --porcelain --no-follow-tags origin refs/heads/<ref>:refs/heads/<ref>. It never pushes a trunk, never pe/integration (a tree several plans meet on; 5.1 leaves publishing it to a person on purpose), never with force, never a delete, never a retry of a rejected non-fast-forward push, and never a name outside ^pe/[A-Za-z0-9._-]+(-p\d+)?$. Two conditions must both hold — the flag is on AND the plan's permission.destructive row names git push as an allowed exception — and without either the landing journals phase.landing-push-refused {reason} and parks: nothing is spawned and nothing reaches the origin. phase-console doctor carries a non-blocking publish row saying whether a push is possible at all. A free console parses the flag and pushes nothing.

The same flag is the console's other outward door: filing an issue on the repository's behalf. It opens only for a draft a session recorded through scripts/phase-issue.sh and only where the plan's Issues: word allows — file files a file or comment draft at once, draft files it on your Approve, a close files on your Approve alone, and off (the default) never reaches the door. Every act is one call of one writer (viewer/server/pro/issues/write.ts) with a fixed argv, a body ending in the provenance footer and the labels — its type, severity:<level>, awaiting-plan and from-session, each created on the repository where it is missing (gh label list, then gh label create without --force); the draft's ledger line records filed {number, url}, commented or closed, or failed with the reason. Since control-tower phase 115 filing has a flag of its own, --allow-issues (file, comment, label — never a push), which --allow-publish implies; phase-console capability <instance> add --allow-issues puts it in an instance's launch unit through the console's own unit writer, and the next update reloads it. Without either flag a file-mode draft waits as pending-approval with the note naming --allow-issues, and the Approve route answers 403 naming it; nothing is filed. With it, the console still holds a draft for a person — never drops one — past its per-day filing rate (20 issues and comments a day by itself, filed in order once the rate allows) and when it is a critical or security finding bound for a public repository. What a session may run ITSELF is untouched: ISSUE_ASK (runner/approvals.ts) still pins gh issue create|comment|close to a card in every profile, so the flag widens what the console does, never what a session may (docs/safety-rails.md).

A phase may take a checkout of its own, or decline one — - **Isolation:** worktree or shared in its ### Phase N section, which outranks the plan-wide **Worktrees:** line in both directions. A phase doing isolated work carves itself in; one that must see its siblings' work as it lands carves itself out. Silence inherits.

A checkout the console mints is missing whatever git ignores, which for a lot of repositories is what a build needs. .worktreeinclude at the repository root fixes that: one path per line, # comments, files or directories, copied into a tree this console just created. It rides the same switch as the .env copy (Settings ▸ Automation), because it is the same decision. Three refusals, each named in the journal rather than silently dropped: a path that resolves outside the repository, a path that is not there, and anything past the 200-file / 50 MB cap.

Two branches can both be editing something that merges cleanly and is wrong afterwards — a lockfile, two migrations, .gitmodules, a generated file. Those are clash zones, and the repository's conflict radar warns about them while serializing the two is still cheap: an fyi row in the inbox, a clash zone badge beside the pair on the run's Branch & checkout card, and run.clash-zone in each involved run's journal. Never a refusal — nothing is broken yet, which is the whole point of saying it now. The defaults cover the four families above; a repository adds its own in .phase-console/clash-zones (one glob per line, ** crossing directories), and a plan adds work-specific ones on a **Clash zones:** line. All three are unioned — a plan naming one glob never turns the lockfile rule off.

The board

#/runs answers what is happening with a board (runsView). Four columns — running · queued · waiting · frozen — a card per live run carrying its plan, its phase strip, its spend, its branch chip and the facts about what it is waiting on; and every verb inline on the card it acts on: freeze, continue, pause, resume, hold, release, stop, priority, bump, and the repair actions. Freeze all and Thaw all are in the header. Alongside the queued column it shows the same ordering advice GET /api/queue carries — remaining weight and an ETA per queued plan — as a suggested order, computed when you ask and never acted on.

The other reading of the same runs is a record: every run there has ever been, sorted and filtered, one row each. It is the right shape for what happened and the wrong one for what is happening — a row cannot say that this run is queued behind that one, and every verb that changes what happens next used to live on some other page: priority on the launch form, hold and bump on an endpoint with no button, Freeze all in Settings, isolation in a settings sheet. runsView keeps the two one toggle apart, and never shows both at once.

Nothing on it is a new control: the columns, the lane rows, the holder facts, the branch chip, the meters and every lifecycle door are the same primitives the Run view and the Runs home use, which is why a verb cannot behave one way here and another way there.

Steering which plan goes first

Four controls, and none of them is a heuristic — the scheduler still runs the same deterministic first-fit scan it always has, and these are inputs to it.

Queue priority (high · normal · low, on the launch dialog and on a live run's settings) decides which class an admission is scanned in. First come, first served still decides within a class, and the starvation bound is untouched: an entry that has been bypassed enough times, or has simply waited ten minutes, reserves its tokens against everything behind it including a higher class. A low plan that starves outranks a high one that just arrived. A class is a preference; starvation is a bug.

Hold and Release (beside Pause/Resume on the run controls) stop a run boarding anything new while its running phases finish and write their handoffs. That is the whole difference from Pause: a pause waits for the next phase boundary and then settles the run, so bringing it back is a Start; a hold refuses the next admission and nothing else, so a Release puts it straight back in the queue. Held entries name who held them on their line, and never age into a reservation — standing aside must not turn a plan into an obstacle.

Bump moves one phase to the front of its class. It never crosses a class boundary (the priority control is what says that out loud). Since control-tower phase 99 (#135) it is DURABLE: the mark lives on the phase's own record, not on the queue entry, so a pause, a retry, a wrap-up, a relaunch or a restart re-creates the entry already bumped, and the run's own boarding order puts the phase first among its siblings; it is spent when the phase boards. Two bumps order by when they were given, the most recent first.

The queue. The Runs home's Queued group holds every run waiting for a scope or a lane; in the free edition #/queue opens that group, as it has since 7.0 (with Pro it is the Queue page, below). A queued run's line says whom it waits on, and opening it says why it sits there — a dependency, an aged-out reservation, its class, a bump, or first come first served — and moves it: Bump, Board P first, Hold. One phase's own place in line is in its Actions, in the panel: Hold in the queue keeps it queued with its age, never admitted, until Release; Defer until… is a hold that ends by itself at a time; Withdraw takes it out — its run boards its other phases and names it until you Requeue at the front. Settings ▸ Automation ▸ The queue decides how the line is ordered. Every one is POST /api/queue/<verb> — bump, hold, release, defer {until}, withdraw, requeue {position?: 'front'}, reorder {slug, phases} (the listed phases first, in that order) — naming an entry by entryId or a phase by {slug, phase}, each journalled on its run with the actor and the reason.

Start after <slug> (launch only) chains a run behind another plan: it boards nothing until that plan's latest run ends — finished, paused, parked, halted or stopped, whichever comes — and the chain survives a console restart because it lives on the run's checkpoint. Its line in the Queued group says Chained behind <slug>. The slug is not validated: one that names no plan settles at once, rather than refusing to start a run whose predecessor you are about to create.

GET /api/queue carries everything the Queued group and a phase's queue Actions draw — the widened entries, lanes, hinted, withdrawn and audit — and ordering advice alongside them: remaining plan weight and an ETA per queued plan, computed when you ask and never stored. Nothing on the server reads the advice back: it is the figure to look at before deciding to use one of the controls above.

The Queue — the whole order, checked before it moves (one-pool phase 39, #380). GET /api/queue?all=1 (the same answer at GET /api/queue/forecast, and phase-console run queue --all [--plan <slug>]) adds forecast beside today's view: every phase of every open plan with a run, in the order the console will board them. It is a replay of admission, not a guess — each run's own ranker and the scheduler's, extracted so both the runner and the forecast call them, over the lanes, the caps, the scopes and the marks — and every row carries its place (now, next, later, held, deferred, withdrawn, skipped, reserved, pinned, hinted — and settled, for a phase that finished today), its position, its eta, its length (the console's working-time estimate at its size), its account, and why: the rules that hold it back, each a sentence. A held, withdrawn, gated or person-owed phase has no ETA, and says why.

POST /api/queue/check {moves | probe} (run queue check <move…>) is a read: it answers yes, yes-with-effects or no for a set of moves, with the rules that decide it, the nearest order that would hold, every phase it displaces with its place and ETA before and after, and the order as it would stand; probe paints every slot a phase could take. A move is <slug>/<N> first | last | next, or <slug>/<N> before | after <slug>/<N> — another plan's phase included. The rules are one table, shared/queue-rules.js, which the scheduler's admission reads too: the hard ones (a dependency, a finished phase, a closed plan, a phase outside the run's scope, a gate, a person's act, a hold, a deferral, a withdrawal) refuse; the soft ones (scope, the whole checkout, a branch hold, the lane cap, a repository's cap, the class, the fair share, an account, the load guard, the boarding hours, a kept lane, a pin, a chained run) let a move stand and say why it boards no earlier.

POST /api/queue/order {moves, reason} (run queue order <move…> --reason <text>) is an authority press behind --allow-run: it runs the check, refuses a no with that verdict — there is no force — and otherwise writes the override on each moved phase's record (queueControl.order). It survives the entry, a retry, a relaunch and a restart, is spent when the phase boards, and lapses to policy when its anchor settles or is withdrawn (phase.queue-order-lapsed). POST /api/queue/order/revert {slug?, phase?} (run queue revert [<slug>[/<N>]]) lifts one, a plan's or every override. The ten verbs above answer the verdict they would have had, and a no refuses them (409): a reorder that lists a phase before one it waits on is refused instead of quietly yielding another order. Each override's life is on the record: phase.queue-ordered when it is written, phase.queue-order-lapsed when its anchor settles or is withdrawn, and phase.queue-order-reverted when a revert lifts it, and in the log queue.ordered (once per move, with its actor, reason, verdict and the order before and after), queue.order-lapsed and queue.reverted.

The Queue page, the supervisor and the fleet (one-pool phases 40–41, #380). With Pro, #/queue is a page under Runs, not a redirect: the forecast as In order, Lanes and By plan, a lift that paints every place a phase could take (one check {probe}), the verdict sheet with Apply, Apply the nearest and Cancel, a few seconds' Undo, and Revert on every order in force — viewer/docs/design.md §The queue is its contract. The supervisor speaks the same three doors: its chat's queue-forecast answers "why is waiting?" from that row's rules, queue-check is a read, and queue-order is an act behind --allow-run that presses POST /api/queue/order — a no is refused with its rule and the nearest order that holds, and each act is one row of the chat's ledger with its move and verdict. A remedy whose press moves the order (the ahead-of-dependency bump) is weighed by the check at the pass and again at the press, an Accept included, and a no is never pressed or offered. Fleet ▸ Queue asks each console for GET /api/queue?all=1, lists every console's waiting phases together, the longest wait first, and POST /api/fleet/queue/order {console, moves, reason} forwards a move to the console that owns it under the caller's login, answering that console's own verdict (a no is its 409); POST /api/fleet/queue/order/revert {console, slug?, phase?} lifts that console's orders the same way, for a script.

Run-level beats plan prose for console-minted sessions. When a run uses the work branch and the plan's own **Branch:** line names a different branch, the session is told to use the run's branch and to record the discrepancy in its handoff — the console never silently rewrites the plan. Hand-driven sessions (copy-paste boot prompts) keep following the plan's line.

Command reference

Run these from the repository that owns docs/, or set DOCS_ROOT.

The engine

scripts/phase-graph.sh <slug>                    # the board (default)
scripts/phase-graph.sh <slug> --lint             # structural validation; non-zero on a problem
scripts/phase-graph.sh <slug> --ready            # ready phase numbers
scripts/phase-graph.sh <slug> --ready-after N    # the ready set assuming N just completed
scripts/phase-graph.sh <slug> --deps N           # N's prerequisites
scripts/phase-graph.sh <slug> --dependents N     # phases N blocks
scripts/phase-graph.sh <slug> --size N           # S | M | L
scripts/phase-graph.sh <slug> --repos N          # N's SCOPE as a csv — the input to every lock check
scripts/phase-graph.sh <slug> --mcp [N]          # the MCP servers the plan (or phase N) needs
scripts/phase-graph.sh <slug> --mcp-policy [N]   # continue | require
scripts/phase-graph.sh <slug> --credentials [N]  # the credential ids the plan (or phase N) needs
scripts/phase-graph.sh <slug> --credential-policy [N]   # require | continue | "" (the phase overrides)
scripts/phase-graph.sh <slug> --accounts         # the **Accounts:** line as id<TAB>minHeadroom per line
scripts/phase-graph.sh <slug> --qa-exhausted     # waive | halt | <owner> | "" (the **QA exhausted:** line)
scripts/phase-graph.sh <slug> --person-check N   # allow | halt | <owner> | "" (phase N's Person-check bullet)
scripts/phase-graph.sh <slug> --decisions [N]    # the decision manifest as it holds: key<TAB>state<TAB>owner<TAB>blocking<TAB>source<TAB>value
scripts/phase-graph.sh <slug> --wait-budget [N]  # minutes<TAB>phase|plan — how long a phase may stay parked on its declared waits; nothing when the plan is silent
scripts/phase-graph.sh <slug> --waits-on N       # the refs phase N's `- **Waits on:**` bullet names, one per line
scripts/phase-graph.sh <slug> --gated N          # yes | no
scripts/phase-graph.sh <slug> --gate-kind N      # human | ai | auto | none
scripts/phase-graph.sh <slug> --gate-status N    # evaluate the gate (approval clears any kind)
scripts/gate-approve.sh <slug> N --by <who>      # record a clearance (--revoke restores the gate)
scripts/phase-graph.sh <slug> --notes N          # what earlier phases LEFT for phase N: source<TAB>kind<TAB>id<TAB>at<TAB>text
scripts/phase-graph.sh <slug> --boot-prompt N    # the copy-paste prompt for phase N (notes block included)
scripts/phase-graph.sh <slug> --boot-fanout "N M" # a fan-out's shared boot once + each phase's own block (#115)
scripts/phase-graph.sh <slug> --session-plan opus   # proposed session grouping
scripts/phase-graph.sh <slug> --qa-mode          # off | on <reason> | waived <reason>
scripts/phase-graph.sh <slug> --qa-result N      # the recorded verdict
scripts/phase-graph.sh <slug> --qa-prompt N      # the QA subagent's brief
scripts/phase-graph.sh <slug> --plan-status      # active | complete | abandoned | superseded
scripts/phase-graph.sh <slug> --closed           # exit 0 if the plan is closed, 1 if open
scripts/phase-graph.sh <slug> --memory-block     # done/ready/waiting, for the memory entry

A closed plan answers differently on purpose: --ready and --ready-after come back empty, --session-plan returns a notice instead of groups, --lint still lints but exits 0, and the board prints a 🔒 CLOSED banner in place of the ready/waiting/batching lines. validate.sh skips it and next-phase-prompt.sh offers no boot prompts. See the artifacts.

The helpers

ScriptWhat it does
new-plan.sh <slug>Scaffold docs/plans/<slug>.md from the template.
new-handoff.sh <slug> <N> <title> [status] [--qa] [--force]Scaffold the phase handoff, update INDEX.md, auto-fill dependencies and write the next phase's boot prompt — for a fan-out, the shared boot once and a block per unblocked phase.
handoff-status.sh <slug>The INDEX, per-file status, and the live board.
next-phase-prompt.sh <slug> <N|none> [--phase M]End-of-phase banner, board, batching advice, and the boot for every newly ready phase (a fan-out's shared boot once); --phase M composes phase M's full prompt.
phase-lock.sh <slug> claim|release|status|list|conflicts <N> [--owner <id>] [--scope <csv>] [--session <id>] [--force] [--git]Claim, release or inspect a phase lock; conflicts asks across every plan whether a live session shares your scope. --session (default $PE_SESSION_ID, else $CLAUDE_CODE_SESSION_ID) names the session in the lock, so the console can release it the moment that session ends.
phase-lane.sh <slug> create|merge|remove <N> [--qa <round>] [--detach] [--repo <token>] [--owner <id>] [--force] · phase-lane.sh list [<slug>]A hand session's own checkout, made where the console keeps its own: a locked worktree of the phase's repository under <root>/.worktrees/hand/<slug>/p<N>[-qa<r>] on pe/<slug>-p<N>[-qa<r>] (or detached), folded back with ff-then---no-ff, removed with worktree remove + branch -d — never a sibling folder of the project.
phase-outcome.sh <slug> <N> complete|waiting-external|blocked|needs-human|partial|no-defect [--reason …] [--needs KEY] [--rule …] [--command …] [--wait-minutes M | --until ISO] [--watch ref]Declare how a session ended, machine-readably — the runner's channel (PE_OUTCOME_FILE); unsupervised, it lands in the console's inbox and is picked up the same way. --needs <key> is REQUIRED on blocked and needs-human (a decision key of the plan's manifest, or credential / permission / gate / external / lock) and is what the classifier reads before the prose. --wait-minutes/--until are accepted only with the three that PARK (waiting-external, blocked, needs-human). no-defect is "I looked, and there was nothing to fix". --watch is repeatable (at most 8: gh:<repo>#run/<id> · gh:<repo>#pr/<n> · date:<ISO> · lock:<slug>/<phase> · cmd:"<command>"); a ref of no shape the console polls is warned about on stderr ("will never be checked") and journalled phase.watch-unpollable. What the console does with the words: a waiting-external window is judged against the phase's wait budget (--wait-budget above: its Waits on: bullet, else the plan's Wait budget:, else the console's 8 h) — granted inside it, and a declared window past it refused with the arithmetic unless the plan countersigned a date: reaching that far; a blocked or needs-human clock is capped at 7 days (DECLARED_CLOCK_MAX_MS) and the cap journalled. Each word is acted on at most 4 times per phase (DECLARATIONS_MAX_PER_PHASE; past it phase.declaration-refused, and Retry clears the count), waiting-external being bounded by its own park count instead; unsupervised, a second partial inside 5 minutes collapses into the first (DECLARATION_COOLDOWN_MS).
phase-outcome.sh <slug> <N> ruling --what "…" [--why "…"] [--kind ambiguity|deviation|deferral] [--cost-if-wrong "…"] [--for <M|next|all>] [--needs <key>] [--remember plan|global] [--by WHO]The same script's second shape: what a session decided, as opposed to how it ended. Appends one NDJSON line to the plan's ruling ledger (PE_RULINGS_FILE, else runs/<instance>/<slug>/rulings.ndjson), stamped with its id and — with --needs — the decision key it answers (a manifest key, never a blocker short form). A ruling is never an outcome — nothing acts on it, and declaring one does not declare the other. --remember plan promotes it at once (a ## Decisions row through decisions.sh promote, source ruling, then an attributed ack); --remember global asks the owning console to hold the words as its policy.<key> answer and exits 1 naming Settings ▸ Automation ▸ Policy answers when no console answers.
phase-outcome.sh <slug> <N> needs-human --needs <key> --step <kind> --title "…" --why <reason> --guide <file> [--lang <tag>] [--proof <ref> | --proof-words "…" | --proof-type probe|answer|judgement|attest] [--effort <min>] [--due <ISO> | --due-when <ref>] [--unblocks <phases>] [--window <duration>] [--option <id>=<label>…] [--tried "…"]The same script's step form: an act only a person can do, raised as an item on Your turn (--act is --step operator-act). --why names one of the ten reasons (absent, the kind's default, marked inferred) and --guide the full guide; a step with no proof is refused. Exits: 0 recorded · 2 malformed, or refused by a rule of the door (no proof, a secret in a value, a guide over its limits) · 3 the proof already holds, nothing raised · 4 the guard refused — the AI could do it itself, or nothing refused it. references/turn.md has every flag.
phase-outcome.sh <slug> <N> handled --what "…" [--note "…"] [--link …]The script's third shape: what a session settled without asking a person, for Handled by the AI. It is never an outcome; a link is held to a commit, a pull request, an issue or a journal line, and every field goes through the secret screen. The row lands in handled-sessions.ndjson, read as the session's and never as the guard's.
session-hook.shThe user-scope Claude Code hook — four entries, SessionStart · SessionEnd · Stop · Notification — that reports a session to the console owning its directory; installed by phase-console install-hooks or Settings ▸ Automation ▸ Session presence. Notification carries the ask's own message and its notification_type. The owner is resolved through the registry (viewer/shared/instances.mjs owner, from $DOCS_ROOT when it is an absolute existing directory, else the session's cwd) with no only-console fallback; a directory no registered console claims is recorded unowned in the machine's sink, <state home>/fleet/sessions/inbox/, rather than dropped or filed against the wrong console. The record is POSTed with a 2 s timeout; when no console answers it lands in the instance's sessions/inbox/, and when the console refused the connection and there is a node, the hook drains that inbox itself with phase-console sessions ingest — in the background, except at SessionStart, where it waits for the peers line it puts in the new session's context. PHASE_CONSOLE_HOOK_INGEST=0 leaves the inbox for the console; PHASE_CONSOLE_HOOK_OFF=1 silences the hook. Always exits 0.
decisions.sh <slug> [--phase N] answer <key> --value … | waive <key> --reason … | promote --from-ruling <id> --key <key> | listWrite the decision manifest's twin (docs/handoffs/<slug>/decisions.md), which --decisions merges over the plan's own ## Decisions rows — never edit it by hand. answer records a value, waive that the decision does not apply, promote turns a ruling into a standing answer (found by the id phase-outcome.sh stamps on the RULING line, never its ack; --key is optional for a ruling that carries its own decisionKey); --phase N scopes the row to one phase.
qa-record.sh <slug> <N> <pass|fail|waived|pending> [--report <path>] [--round N] [--reason TEXT]Record a QA verdict. --round defaults to previous + 1 and is what writes the ## QA rounds ledger (refused with pending); --reason is waived-only and lands in ## QA waivers. Read the history back with phase-graph.sh <slug> --qa-history N.
close-plan.sh <slug> [--status abandoned|superseded|complete] [--reason "…"] [--reopen] [--force]Close a plan that will never finish, or --reopen one. Sets status:, closed: and closed_reason:, and releases the plan's own phase locks. complete is refused while the run branch pe/<slug> is off its origin trunk, a landing row is conflict/failed, or a plan:<slug> issue is open — --force closes over them and records it (#320). Idempotent; reads git, never writes it.
validate.sh <slug>Full validation — plan structure and handoff consistency.

The console's own commands

CommandWhat it does
phase-console install-hooks | uninstall-hooks | hooks-status [--settings <file>]Add, remove or report the four session-presence entries in ~/.claude/settings.json (or the file --settings names) — merged, never clobbered, idempotent. hooks-status exits 0 when they are installed and 1 when not.
phase-console doctor [instance] [--json]The report described in the control surface: the prelude's probes and the machine rows, from the console on the instance's port or, with none, from disk (mode: offline). Exit 1 names the first failing row that blocks; --json prints the report as GET /api/doctor serves it.
phase-console report [instance] --since <iso> [--until <iso>] [--replay] [--json]The week in numbers, read from the instance's state directory with no console needed: how much of each run's working life it sat stopped and who ended each stop, the stops today's rules would not make, failure-streak and verify-failed halts, phantom spend, the queue, the holder and phase ETAs, stall cards on finished lanes, resumes cut by the closeout cap, phases closed with an unrun verification, the model each phase ran on, and what re-reading the context cost (cache-read volume, the mean context a call re-read, the boot prefix's share) — then the plan-acceptance targets judged on them. --replay re-derives the streak, spend, ETA and holder numbers with today's models over the same journals. It never writes, and ships in both editions.
phase-console sessions ingest [instance] [--root DIR] [--json] [--quiet] [--peers-of <session>]Drain an instance's session-presence inbox through the registry's own code with no console up: every event applied with its lateness, one older than the history horizon applied as history, one older than a week refused. It never drains under a console — when the instance's own console answers on its port, or the port answers too slowly to tell, it says so and does nothing, because a running console drains its own inbox. Prints drained <name>: N applied (N as history), N refused, N left; --peers-of adds one peers=<sentence> line, the live sessions in the root without that one. One drain at a time across processes.
phase-console owner enroll | status | lock [--console <name|port>] [--json]The owner key from a terminal, in both editions, over the console's loopback. enroll prints the first key's one-time link (good for ten minutes) to open in a browser on this machine — a console that already has a key refuses, and a later key is enrolled from a browser signed in as the owner; status says whether the door is unenrolled or enrolled, with the keys and the requests waiting (unlocked is what a browser inside an owner session reads in /api/state); lock ends every owner session. A supervised session's enroll and lock are denied at its hook (console-forge).
phase-console grants list | revoke <id> | revoke-all [--console <name|port>] [--reason <words>] [--json]The scoped grants from a terminal: list shows each grant's rule, scope, who gave it through which door, until when and what it changed; revoke ends one live grant and revoke-all every one, undoing exactly what each row says. Taking authority away needs no owner key; a supervised session's revokes are denied at its hook.

With more than one console on the machine, the same bin carries the machine's verbs:

CommandWhat it does
phase-console list [--json]The census: every console any of its five sources names (the registry, the state directories, the prefs files, the units, the launchers), one row each — NAME · ROOT · PORT · STATUS · PROVENANCE · UNIT — plus a count of presence events no console claims. --json is byte-for-byte what GET /api/instances serves.
phase-console remove [instance]Forget a stopped console's registry row; its state directories stay. Refused for a console running by its heartbeat, or answering on its port as itself.
phase-console profile [set <key> <json>] [--instance <sel>]Print the machine profile as every console reads it (with --instance, what that console takes from it), or write one field — with --instance, as that console's override.
phase-console autostart [instance] true|false|onceThe start policy a login and a boot read; with no word, the one in force.

STATUS is the census's one liveness word (LIVENESS): running (a heartbeat inside HEARTBEAT_STALE_MS, three missed 30 s beats, and no clean exit after it) · stopped (a clean exit recorded, a heartbeat gone stale, or nothing registered) · orphaned (the row's root no longer exists) · port-taken (not running, and a running sibling's heartbeat holds its port) · unknown (a row no console ever heartbeated). PROVENANCE is one of CENSUS_PROVENANCES: registry · state-only · orphaned. The word is read from what each console wrote — its heartbeat and its clean exit — never from a probe, so list, agent.sh status and GET /api/instances cannot disagree about a row.

The machine profile

~/.config/phase-console/fleet.json holds what is true of the machine rather than of one project. Every console on the machine reads it; it is written from a shell, never from a browser:

FieldWhat it is
remoteHost · remoteUsers[]The --remote hostname and the logins allowed through it — inherited as a pair, so a half-written pair leaves a console local-only rather than refusing to boot
notifyCommandThe out-of-band notifier, used when PHASE_CONSOLE_NOTIFY is unset
webhooks[]{url, name?, categories?}
categories{<id>: bool}
quietHours{start, end, allowUrgent}
maxSessionsThe machine's lane ceiling — every console's live lanes summed
hookScriptAn absolute path; a relative one is ignored
instances.<id>{autostart, overrides} — one console's start policy (true, false or once, above) and its own values for the overridable fields

Each field resolves in one order: a flag or environment variable wins (--remote, PHASE_CONSOLE_NOTIFY), then this console's overrides, then the machine-wide value. Six fields can be overridden per console (OVERRIDABLE_PROFILE_KEYS: remoteHost, remoteUsers, notifyCommand, webhooks, categories, quietHours); maxSessions and hookScript are the machine's alone. GET /api/fleet/profile shows what this console takes from the file with each field's source named, so an override is visible rather than a surprise.

One more field is the machine's alone, and is the fleet supervisor's to act on: reach — {mode: "tailscale" | "direct", host?, listen: {address, port}, tls: {mode: "local-ca" | "custom", cert?, key?}, acknowledged?}, how a phone reaches the fleet (many-plans-one-repo phases 21–23). Under direct the supervisor opens an HTTPS door on listen.address:listen.port (one literal address of this machine, never 0.0.0.0 or ::; the port defaults to 4443), serving host — the address itself, or a name that resolves to it — under a certificate it mints from a local CA (local-ca) or the PEM pair you name (custom, absolute paths, re-read every minute). A host that classifies public opens only once acknowledged names that exact host. A block the profile cannot use is dropped with a named rule (REACH_DROP_RULES), and the door then reads unconfigured with that sentence rather than falling back quietly. Written with phase-console fleet reach … or phase-console profile set reach '<json>'; the supervisor re-reads it within 5 s while it runs, and the door opens, moves or closes with no restart.

The fleet supervisor

One process for every console of the machine — com.phase-console.fleet, bound to 127.0.0.1:4100 and nothing wider — driven from the same bin:

CommandWhat it does
phase-console fleet install [--port N] [--remote HOST --remote-user LOGIN] [--publish-serve]Install the supervisor's unit, through agent.sh fleet.
phase-console fleet uninstall | start | stop | restart | status | log [-f] | renderThe supervisor itself.
phase-console fleet <start|stop|restart|freeze|thaw|logs|status> <instance>That verb on any console, carried out by the supervisor (FLEET_VERBS): start, stop, restart, status and logs through that console's own agent, or the bin's foreground fallback when it has none; freeze and thaw as that console's own Freeze all and Thaw all, asked over loopback.
phase-console fleet hold [--by NAME] | releaseHold every automatic start on the machine, or release the hold. With no supervisor running, the hold file every console reads at fire time is written or removed directly.
phase-console fleet instances [--json]The census as the supervisor serves it, and who holds the machine.
phase-console fleet reachHow a phone reaches the fleet: the mode, the profile's reach block and the door's facts from GET /api/fleet/reach — or the profile alone with supervisor not answering when nothing does.
phase-console fleet reach tailscaleBack to Tailscale Serve. The direct block is kept with its mode amended, so the address survives a switch. Through POST /api/fleet/reach when the supervisor answers, else written to the profile directly, which the supervisor reads within 5 s once it runs.
phase-console fleet reach direct --listen <addr> [--port N] [--host <name|ip>] [--tls custom --cert <pem> --key <pem>] [--acknowledge <host>]Open the direct door on one address of this machine. A public host is refused with the sentence and exit 1 unless --acknowledge names that exact host; a block the profile would drop is refused with its rule. The same two routes as above.
phase-console fleet pair [--label <text>]POST /api/fleet/reach/pair-code: the URL, the XXXX-XXXX code and its expiry, as text — the QR is the fleet app's. Exit 2 with no supervisor.
phase-console fleet devices [revoke <id>] [--json]The register as a table — id, label, via, site, standalone, lastSeenAt — or one device revoked. Exit 2 with no supervisor.
phase-console fleet password [--clear | --stdin]The pairing password, prompted twice with no echo on a TTY or read as one line from stdin, at least 12 characters: PUT/DELETE /api/fleet/reach/password when the supervisor answers, else written through the same hash file it reads at boot.
phase-console fleet ca [--export <path>] [--rotate --yes]The local CA's path and fingerprint256 (works with the supervisor down); --export copies ca.pem; --rotate --yes re-mints CA and leaf and names the devices that must install the new CA — exit 2 with no supervisor, and no route does either.
phase-console doctor fleet [--json]Three rows block — the unit installed, the supervisor answering on its port from a live beat, its bearer readable — and four advise: the census, the usage poller, the fleet subscription and the Serve handler, which reads skip with the command until it is published (under a direct reach: "Tailscale Serve is not this fleet's door"). Six more judge the door — reach-mode, reach-listen, reach-exposure, reach-tls, reach-devices, reach-password — blocking while the profile's mode is direct and advisory under tailscale: an empty register is skip naming fleet pair, and a certificate under 14 days is ok with a warning.

Its routes: GET /api/fleet/health · GET /api/fleet/instances · POST /api/fleet/instances/<instance>/<verb> · GET /api/fleet/hold, POST /api/fleet/hold, POST /api/fleet/release · GET /api/fleet/inbox[?all=1] (every console's asks and the supervisor's own health rows, worst-first) · GET /api/fleet/turn (every console's Your turn merged into one list, each item's endpoints under /c/<id>/ so a press lands on the console that raised it) · POST /api/fleet/announce (a console's announcement, fanned in to the fleet subscription) · POST /api/fleet/join (where phase-console start registers) · GET /api/fleet/push and POST /api/fleet/push/{subscribe,unsubscribe,categories,test} · a console's own pages under /c/<id>/ · the reach routes (phases 22–23), each with its rule — GET /api/fleet/reach (any served caller; carries caller: {scope, device} so a page knows whether it may write) · POST /api/fleet/reach (a whole block, or a body of only {acknowledged} or only {mode} that amends the current one — local, or a Tailscale-verified login; never a paired device, 403 reach-settings; a dropped block is 400 with its rule) · POST /api/fleet/reach/pair-code and DELETE …/pair-code/<code> (local, Tailscale-verified, or a paired device) · GET /api/fleet/reach/devices (any served caller) · POST …/devices/<id>/revoke and …/rename (the same three) · PUT/DELETE /api/fleet/reach/password (local only, 403 password-local) · POST /api/fleet/reach/logout (a paired device, for itself) · GET /pair/ca.pem on every door. Through a console's relay, any reach mutation needs that console's own verdict local and its --allow-run, and the relayed GET /api/fleet/reach carries the console's verdict as caller, not the supervisor's loopback word.

A console relays the fleet app's calls to the supervisor at /api/fleet/supervisor/… — the supervisor's /api/fleet/… routes, and a console's /api/… under /c/<id>/ — over loopback, status and body unchanged. Writes carry that console's cross-site guard, and the verbs that change what runs on the machine (instances/<instance>/<verb>, hold, release) also need that console's --allow-run; the person it identified is the by the supervisor records. The machine hold binds every console, the one relaying included, which carries it beside its own freeze as state.fleet.hold; live sessions keep running under it.

The supervisor and its chat

Two things answer to the word on a Pro console, and they are different machines.

The supervisor's pass (control-tower phase 101, viewer/server/pro/supervisor/) watches every run this console drives for a stuck state it recognises (SUPERVISOR_SITUATIONS) and, by its policy, records it (observe), offers the remedy as a standing card (suggest) or presses it (act) — through the same Service method the operator's button calls, once per root cause and inside hourly caps, each act pinned as a note on the run. An operator-only remedy is escalated as an item on Your turn (control-tower phase 136): an operator-act with a guide — the exact command and what you should then see — and the proof its detection names, so it settles itself when it lands. Three remedy rows run over turns, by policy like every other: turn-unopened (nobody opened an item in two hours: a pinned note, and the item said once more), turn-ai-could-do (the second look: an item that says the AI cannot, whose every command its run's policy now allows, is suggested for withdrawal — a person's Accept withdraws it and resumes the session to run the commands itself) and turn-returned (back three times: a guided escalation). The routes:

RouteWhat
GET /api/supervisorThe policy in force, the standing cards and the chat block (availability, the confirm word, every chat).
POST /api/supervisor/policyChange the policy word, plan-wide or for one situation — journalled supervisor.policy-changed {from, to, by}.
POST /api/supervisor/cards/:id/acceptPress a suggested remedy, as the person.
POST /api/supervisor/cards/:id/extendOne more act for the cause a cap held.
POST /api/supervisor/cards/:id/dismissStand a card down; journalled with dismissed: true.

The supervisor chat (control-tower phases 27–28) is a conversation with a claude session in the console's own conversation mode, on the operator's account when it has room and the machine's otherwise. Its tools read the console's state and press the console's verbs through POST /api/supervisor/tool (the tools host calling back, bearer-checked), each as the supervisor door with via: 'supervisor-chat' and journalled supervisor.act; a tool whose capability flag is off is refused before anything is pressed. The chat keeps its three human-step tools and gains turn-explain, a read, and turn-raise, which raises an item and decides nothing; no tool answers, declines, attests, overrides, grants, denies or writes a verdict, and an act of the chat answers a permission card or opens a manual gate only when a person's door allowed it on its card. An act the chat proposes waits for the operator's confirmation under the confirm word in force. A chat resumes its session until the resume gate says otherwise — past the 250k context rule, under another account, or at its window's wrap-up line — and then starts a new thread (supervisor.chat-thread). The conversations live under the console's state directory.

RouteWhat
POST /api/supervisor/chatsA new chat, and its first message when the body carries one.
GET /api/supervisor/chats/:idOne chat: its transcript and state.
POST /api/supervisor/chats/:id/messageSend the next message.
POST /api/supervisor/chats/:id/interruptStop the answer in progress.
DELETE /api/supervisor/chats/:idDelete a chat.

On a desk the chat is the panel's second mode, Supervisor (⌘. or ?chat= opens it); on a phone it is #/chat, the tab bar's fourth slot. The guide's Supervisor section is the person's account of both.

The license

The Pro package (phase-console-pro) runs its Pro surfaces only while this machine holds a license; a source checkout — any tree carrying both free/manifest.json and scripts/build-free-tree.mjs, which the package never ships — reads source and asks for nothing. One file holds the license for every console on the machine: license.json under $XDG_STATE_HOME/phase-console/ (~/.local/state/phase-console/ when it is unset), mode 0600, holding the signed file the license server returned and the high-water mark that defeats a clock set back.

CommandWhat it does
phase-console license activate <key>Checks the key offline (a typo in PCP-XXXX-XXXX-XXXX-XXXX is refused with no request), then asks the license server for this machine's place and stores the signed file. At the key's limit it names the machines that hold it, so one can be freed on the account page.
phase-console license status [--json]The state and its dates; when a check is due it asks the license server first. Exit 0 while Pro answers (source, active, grace), 1 otherwise.
phase-console license deactivate [--local]Frees this machine's place and removes the file; --local removes it without asking, for a machine already freed on the account page.

The states are source, active, grace, expired, revoked, unlicensed and invalid. A running console asks the license server again when a check falls due (every 7 days) and keeps Pro through 30 days without an answer; a revoked answer locks at once. Outside source, active and grace, what free/manifest.json's gated object names is refused, and never anything else: a gated route answers 402 {error: "license", state, next}, a gated CLI verb or --agent-* flag exits 2 before it runs, and a gated event family is not sent. Each refusal carries the license errand — {kind, state, need, how}, whose how is the next sentence, and it is what the console's own pages show about the license. It is an errand, never a halt: the free verbs keep working. The license's own surface is never gated — GET /api/license answers the state (state.license on GET /api/state carries the same object, and the license:state event sends it when it moves), and POST /api/license/activate, POST /api/license/refresh and POST /api/license/deactivate are the three acts, each with the console header and no capability flag.

PHASE_CONSOLE_LICENSE_SERVER names the license server (the site's own origin by default); a file is trusted for its signature, never for where it came from, so pointing it elsewhere can refuse a license but never grant one. PHASE_CONSOLE_LICENSE_KID is for tests: on a source checkout it turns source mode off and trusts the test key under viewer/test/, so a suite can boot this tree as the package runs.