cyberine
UsageRun recipes

Task workflow inside cyberine chat — shared manifest with the nf-tasks skill

/run and the planner TASK: line dispatch ad-hoc text; the four slash commands below let the chat operate directly on the project's task manifest — the same .claude/TASKS.md / .codex/TASKS.md / .local/tasks/ACTIVE.md file the Claude Code nf-tasks skill (the oc-tasks tool) reads. Both sides read and write the same backlog without clobbering each other: cyberine status flips are APPENDS to .local/tasks/status.log, never rewrites of the manifest (details at the end of this section). The normative format spec — file discovery, block grammar, id minting, status/overlay semantics, the fields table, the lock protocol, and every known divergence between cyberine and the nf-tasks skill — lives at docs/tasks-manifest.md.

Brainstorm to board — the add_tasks planner tool (2026-09-28)

Ask the chat to put a plan on the board ("ghi task list len board", "save these as tasks") and the planner calls add_tasks, which writes through the same validated writer as cyberine tasks save (convergeApplyUnderLock in src/tasks/boardWrite.ts): ids are minted there, deps may name an existing id or @<ref> of another task in the same call, every task needs title, do, doneWhen and effort (S/M/L), exec is always agent, model defaults to sonnet, status defaults to todo (backlog for a draft), and a brief with the six sections is optional but recommended because the implementing session reads it. The chat prints board: added N task(s): <ids>, and the planner is told the board changed only when that result came back. Then run them:

cyberine chat                                  # brainstorm, then: "ghi task list len board"
cyberine tasks status                          # see what landed
cyberine autopilot --engine claude --dry-run   # the plan: one driven Claude seat session per task
cyberine autopilot --engine claude             # run it (asks before spending)

In plan permission mode add_tasks is refused until present_plan is approved, like dispatch_run. The web chat does not carry the tool (its tool set stays read-only).

Known limitations and deferred work: the tool only creates tasks; it cannot edit, close or drop one (use cyberine tasks set / status / drop). The chat does not start the autopilot itself; the operator runs the command. Before this tool existed the planner had no board writer at all and, asked to write tasks, answered that it had written them while the manifest stayed empty (measured 2026-09-28 on deepseek-flash); the prompt now forbids claiming a write without an add_tasks result, which a model can still ignore.

Manifest discovery + task shape

The reader (src/tasks/manifestReader.ts) walks these candidates in order and uses the first that exists:

.claude/TASKS.md
.codex/TASKS.md
.local/tasks/ACTIVE.md

Every task uses this header shape (case-insensitive field names; R\d+ = recurring, the rest are one-shots):

## T05 [todo] Add webhook signature verification
deps: T03
exec: agent
model: sonnet
area: api
do: Add HMAC-SHA256 verification to the inbound webhook handler.
done-when: Tests pass and the verifier rejects tampered payloads with a 401.

Recognised fields: deps: (comma-separated T\d+ / R\d+), exec:, model:, area: (affinity hint for grouped fan-out), blocker:, do:, done-when:. Status tokens: [backlog] [todo] [doing] [done] [forbidden]. A [todo] task is runnable when it has no blocker: and every deps: id is [done]; blocked when at least one dep is not yet [done]; waiting when it carries a blocker: line (external hold — clear the line to release). [doing] is in-flight, [done] closed, [forbidden] is a permanent tombstone that no command will launch.

Verify: npm run selftest:runnable-blocker (k3qcc). A throwaway git repo holds a nine-task board read through the production readManifest() + computeRunnable() — the same two calls cyberine autopilot makes — and asserts the two exclusions a dispatcher would otherwise walk into: a [todo] + exec: agent task held only by a prose blocker: line never enters the runnable set even when every dep is [done], and a [backlog] task never does whatever its exec: says. It also pins the neighbours (an unmet dep is blocked, not waiting; a [forbidden] tombstone is in neither) and checks every task lands in exactly one bucket, so nothing is silently dropped. Positive control: clearing the blocker line on that same task, changing nothing else, makes it runnable.

Which guard actually holds this, measured: computeRunnable's loop routes a blocker-carrying [todo] into waiting BEFORE calling isRunnableTask, so the if (t.blocker) return false; inside that helper is unreachable for this case — deleting it leaves the smoke green. Deleting the loop's if (t.blocker) branch reddens four assertions, and deleting both reddens seven, including the reported failure mode itself (the held tasks entering the agent-runnable set a session is dispatched from). The helper's guard is kept as defence for any future direct caller; the loop is the one that protects today's behaviour.

/tasks — list the backlog grouped by effective status

Header carries per-status counts (doing / todo broken down into runnable + blocked + waiting + still-todo / backlog / done / forbidden) plus the full runnable list — the same ids /dispatch accepts. When nothing is runnable the message names the reason (unblock deps or mark them done / N waiting on an external blocker — clear the 'blocker:' line to release / all todo tasks already in-progress or done / no todo tasks in manifest).

3 doing | 5 todo (2 runnable, 1 blocked, 2 waiting) | 1 backlog | 4 done | 0 forbidden

Runnable (2 — dispatch with /dispatch <id>):
  T03  next: handlers  [deps: T02]
  T07  next: webhook sig  [deps: T03]

Doing (3):
  T05  Add webhook signature verification

Effective status = the manifest's base token OVERRIDDEN by the last status.log line for that id (see "Shared-manifest interop" below) — so what /tasks shows is the same view oc-tasks status produces.

/task <id> — show one task's full body

Prints id, status, title, deps annotated with each dep's effective status (T03(doing) etc.), exec:, model:, blocker:, do:, done-when:, plus a runnability hint that mirrors the /dispatch gate so you see the same reason a launch would refuse. Accepts T-id and R-id:

T05 [todo] Add webhook signature verification

deps:      T03(doing)
exec:      agent
model:     sonnet
do:        Add HMAC-SHA256 verification to the inbound webhook handler.
done-when: Tests pass and the verifier rejects tampered payloads with a 401.

(blocked — waiting on: T03)

Usage: /task T05 (or /task R03 for a recurring task). Bad id format (/task 5, /task T05a) prints usage: /task <id> (e.g. /task T05).

/xmode — execution mode (compete | swarm | research)

Two /mode-shaped commands exist in the chat and they're orthogonal:

  • /mode — the permission mode (default / acceptEdits / plan / auto / bypassPermissions / dontAsk — the Shift+Tab cycle that gates whether /run is allowed to dispatch). See the permission-mode table above.
  • /xmode — the execution mode documented here. It controls WHICH fleet topology /run and /dispatch use, not WHETHER they are allowed to dispatch.

The chat's /dispatch (and /run, the planner dispatch_run tool, /backlog run, /workflow, Ctrl+B) gates on /xmode independently of /mode, so a permission-mode of acceptEdits plus an execution-mode of swarm is valid and still surfaces the "not built yet" line.

/xmode:

  • /xmode (no arg) — cycle forward through compete -> swarm -> research -> deep -> smart -> rush.
  • /xmode <name> — set directly. Accepted values: compete | swarm | research | deep | smart | rush. Unknown values echo the valid set.
  • The active mode is surfaced in the chat status line (fleet: compete; compete/deep/smart/ rush use the accent colour, swarm / research stay muted) and persisted on the session record (ChatSessionRecord.executionMode), so cyberine chat --continue / --resume <id> restores it.

compete (the default), deep, smart, and rush are wired up — all four dispatch through the same /run path (one task -> N agents -> judge picks the winner), differing only in fleet size / model tier / judge chain (T324). research dispatches the standing research workflow (plan -> search fan-out -> verify claims -> synthesize report) — but only from /run (khh1q):

/xmode research
/run "what are the trade-offs between SQLite and DuckDB for embedded analytics?"

prints the dispatched runId and the report path immediately, then — once the run reaches a terminal state — the verified/dropped claim counts and the same report path (also written to disk at .local/research/<slug>.md). swarm, and research on every OTHER dispatch path (/dispatch, the planner dispatch_run tool, /backlog run, /workflow, Ctrl+B), are not wired — they refuse with a one-liner rather than silently downgrading to compete:

fleet: swarm — dispatch not built yet (T234-243)  (use /xmode to switch to a wired fleet: compete | deep | smart | rush)
fleet: research — only /run dispatches a research run today; Ctrl+B, /dispatch, and workflow tools are not wired for research yet  (use /xmode to switch to a wired fleet: compete | deep | smart | rush)

Known Limitations and Deferred Work: no cancel path yet for a research run dispatched this way (it is a plain detached child process, not registered with the control.json protocol /run's compete-mode dispatch polls); /dispatch, the planner's dispatch_run tool, /backlog run, /workflow, and Ctrl+B remain unwired for research even though /run now works.

/settings — the visual settings screen (T418)

/settings          # open it; Esc closes

Opens the four-tab settings screen inside the chat REPL: Chat, Appearance, Dispatch, Advanced, covering 39 knobs. It is the first instance of the interaction standard in docs/design/tui-config-interaction-standard.md — tab bar, type-to-filter box, two-column label/value list, and an inline editor per row.

Keys: Tab / Shift+Tab switch category, any printable character types into the filter, Up/Down move the cursor, Enter edits the selected row, Esc closes (or cancels an edit). A row's editor depends on its type — a toggle flips on Enter, an enum opens an inline Up/Down list, and a free value becomes an inline text field (Ctrl+U clears). Values that are still at their default render muted; anything you have set renders in the accent colour, so a glance tells you what this machine actually overrides.

Writes go to <CYBERINE_HOME>/.env through the same upsertEnvKeys writer /config theme and /config model already use — one writer for user-set values, not two competing ones — and are mirrored into process.env so they apply to the running process.

/config is unchanged and stays as the scriptable shorthand for its five Chat keys.

Run recipe:

cyberine chat            # then type /settings
npm run selftest:settings-registry   # headless: registry integrity + validation + no dead knobs

Known limitations and deferred work. Read this before assuming a knob took effect:

  • Not verified on a real TTY. Ink cannot render headlessly and this repo has no TUI test suite (snapshot tests were removed 2026-06-12 and must not return). What is proven is the logic in src/tui/settings/registry.ts — 32 assertions, positive-controlled. The look — column alignment, windowing at small heights, theme contrast — is unverified and belongs to R01 / T389 / T543.
  • Some knobs need a restart. The screen writes and mirrors into process.env, but a knob read once at startup (CYBERINE_HOME most obviously) will not change the running process. Each such row says so in its help line; the screen does not currently mark them with a badge.
  • Three of the six named primitives exist. TabbedView, KeyHintFooter and the settings screen shipped. SearchableList was NOT extracted — its search box and windowing live inside SettingsScreen for now — and the row editors are rendered inline rather than as separate ToggleEditor / EnumEditor / ValueEditor components. That was deliberate: input ownership stays in ONE useInput in SettingsScreen, because two Ink components both calling useInput race for the same keypress. Extracting them as presentational-only components is still worth doing; migrating the fleet hub's folder-picker and run-filter onto the shared list (as the standard proposes) is not started.
  • No validation against live catalogs. Planner model and OpenCode model accept any string; they are not checked against the model catalog the way /model is.
  • The registry smoke asserts every knob's env var is read somewhere in src/, so a switch wired to nothing fails the gate. It does NOT assert the knob is read correctly.

/config [<key> <value>] — view or change chat settings

/config is the single place to read and write all in-session chat settings without memorising separate keybindings or commands.

Bare /config — print current values of every managed key:

chat config:
  fleet      compete
  permission default
  model      deepseek/deepseek-v4-pro
  thinking   on

/config <key> <value> — set one key:

KeyValuesPersists
fleetcompete | swarm | research | deep | smart | rushsession record (executionMode)
permissiondefault | acceptEdits | plan | auto | bypassPermissions | dontAsksession record (permissionMode)
modelany model id (e.g. deepseek/deepseek-v4-pro)CYBERINE_CHAT_MODEL env key
thinkingon | off | true | falsein-session only

Examples:

/config fleet deep
/config permission acceptEdits
/config model openai/gpt-4o
/config thinking off

Each successful set echoes a confirmation line (e.g. config: fleet -> deep). Unknown keys or invalid values print the valid set without erroring.

The fleet and permission keys are the same settings cycled by /xmode and /mode (and Shift+Tab) — /config is just an alternative path that accepts the value directly without cycling.

/dispatch <T-id...> — claim + launch task(s)

With one or more ids (/dispatch T05 T07), the command parses a whitespace- and comma-separated list (normalizes and dedupes ids) and claims through the same board lock as cyberine tasks run. If any id is invalid or unavailable, the whole selection is refused before launch.

Per-id gate (every non-runnable id is refused with a specific reason):

T00: not in manifest
T01: already [done] — /next for what's runnable
T02: already [doing] — /agents to watch it
T04: [backlog] — flip to [todo] first (oc-tasks doing T04)
T09: [forbidden] — permanent tombstone, never runs
T11: held by blocker: <text> — clear the 'blocker:' line to release
T12: blocked — waiting on: T03, T07
T13: no do: and no title to dispatch — edit the manifest

do: is the task prompt sent to the worker; if both do: and the ## Tnn title are blank the manifest entry is not enough to launch a run and /dispatch refuses with the last line.

Valid ids are claimed BEFORE launch ([todo] -> [doing] written to the status overlay) and dispatched through the active execution mode. Only compete is wired up — in swarm or research the whole command refuses with the "not built yet" line before any claim or launch. On a successful launch the chat prints:

dispatched 2 task(s):
  T05 (Add webhook signature verification)
    runId: abc12345...
    log:   .local/runs/abc12345.../console.log
    watch: cyberine agents
  T07 (next: webhook sig)
    runId: def67890...
    log:   .local/runs/def67890.../console.log
    watch: cyberine agents
  status flips on completion: done on success, todo on failure

If the launch itself fails after the claim, the overlay flips back to [todo] so you can re-dispatch without manually editing the manifest. When the run reaches a terminal state, the chat's completion handler auto-flips the task: [done] on success, back to [todo] on error / cancelled — so the chat is a full loop (pick a T-id, watch it run, see the backlog reflect the outcome) without leaving the chat.

/dispatch with no args claims everything currently runnable — the one-shot equivalent of "claim every free task":

dispatch: claiming all 2 runnable task(s): T03, T07

plan and dontAsk permission modes additionally block /dispatch with plan mode: /dispatch blocked — switch /mode first / dontAsk mode: /dispatch blocked — only pre-approved actions allowed. The claim happens AFTER both gates, so a blocked-mode dispatch never produces a partial status flip.

Shared-manifest interop — how cyberine and the nf-tasks skill coexist

The Claude Code nf-tasks skill (the oc-tasks tool's run / done / doing actions) and the cyberine chat both read the SAME manifest and flip status on the SAME tasks. The naive approach — rewriting the manifest on every flip — loses updates under concurrency (last-writer-wins on the whole file) and disturbs every other agent holding the manifest in context.

Cyberine solves this with a side overlay file:

<repo-root>/.local/tasks/status.log

Status flips are APPENDS — one line per flip, TSV, same format the nf_append script in the skill writes:

<epoch-seconds>\t<agent-tag>\t<T-id>\t<status>\t<extras>\n

<extras> always carries at least tz:<±HH:MM> — the writer's own UTC offset at flip time (OVERLAY-TZ-TOKEN, agreed with nf-tasks 2026-09-17) — ahead of an optional evidence:<text> / fail:<reason> breadcrumb, e.g. tz:+07:00 alone or tz:+07:00 evidence:typecheck 0. This is what keeps completed: and the journal's ts showing the same day on every machine that reconciles the rsync-mirrored board, in the flipper's own timezone, instead of the reconciling machine's; a line written before this token existed has no tz: and still folds exactly as before (see docs/tasks-manifest.md section 7 for the full grammar and validity rule).

Concurrent O_APPEND writes of short lines serialise in the kernel and never interleave or clobber — no lock required. Both sides resolve the path through ONE resolver (src/tasks/boardPaths.ts), so worktrees write to the main worktree's log and a submodule with no board of its own anchors to its superproject — the same derivation the skill's nf_resolve performs. Effective status = base token in the manifest, OVERRIDDEN by the last status.log line for that id:

  1. The manifest file is NEVER rewritten by a flip — it stays as the author wrote it.
  2. readManifest reads the manifest then folds the overlay onto every task's status before returning.
  3. The CC skill's own nf_append and cyberine's setStatus write lines in the same TSV format, so the two systems share one backlog.

What this gives you in practice:

  • /dispatch flips a task to [doing] and the same id appears as [doing] in the CC oc-tasks status view (the fold is the same).
  • Closing a task works from either side: oc-tasks done Tnn (CC) and the chat's /dispatch completion handler (cyberine) both append a done line; whichever lands last wins.
  • Concurrent agents — chat + Claude Code + an external oc-tasks run — never clobber the manifest, because every flip is an append.

setStatus() honours NF_TASKS_AGENT for the agent tag (whitespace sanitised; falls back to user@pid otherwise) so the provenance column in status.log shows which side wrote each line — useful when you scan status.log by hand to see who claimed what. Writable statuses are restricted to todo | doing | done | backlog (forbidden is set by hand in the manifest; recurring R-ids are status-only). The fold behaviour — including the agent-tag fallback — is covered end-to-end by scripts/t256-smoke.ts (run on demand; not part of npm run typecheck).

cyberine tasks calibrate — declared effort vs measured context tokens (kj7t5)

The board declares effort: S|M|L per task at draft time, with no way to know whether an S actually costs S-sized context until something measures it. cyberine tasks calibrate closes that loop: it scans every .local/runs/*/autopilot.jsonl (written by the autopilot session driver, src/autopilot/sessionDriver.ts), attributes each turn's context consumption to whatever taskId the record carries, buckets the summed actuals by the task's CURRENT declared effort (read fresh off the board via readManifest), and flags a task whose actual landed outside its declared class's neighbouring range.

Attribution: turns are grouped by consecutive equal taskId within one run's autopilot.jsonl (a turn with no taskId breaks the group but its context still becomes the next group's baseline). A group's contextDelta = contextTokens at its last turn minus contextTokens at the turn immediately before its first turn (0 when the group opens the file). A task spanning several runs gets one row per run/session; the calibration pass sums those rows by id.

Run recipe:

cyberine tasks calibrate                       # table: n/median/p90/median-cost per class, plus outliers
cyberine tasks calibrate --json                # the same report as JSON
cyberine tasks calibrate --runs-dir <path>     # scan a different runs directory (default .local/runs)

Writes two files under .local/autopilot/ (full regeneration each run — this is derived data, never hand-edited):

  • effort-actuals.jsonl — one row per (task, session): id, declared, contextDelta, costUsd, turns, sessions, date.
  • calibration.json — { version, generatedAt, windowTokens, classes: { S, M, L }, outliers }. Each class reports n, median, p90, medianCostUsd; below n = 3 samples the last three are null (printed n/a — too few points for a percentile to mean anything). An outlier is a task whose summed actual sits above the NEXT class's p90 or below the PREVIOUS class's p10 (the check is skipped when that neighbour class has zero samples); inferred names the class whose measured range actually contains the actual, checked against every class EXCEPT the declared one (a class's own range trivially contains its own extreme member, which would make every outlier "infer" straight back to the mismatch it was just flagged for).

With zero autopilot history, it exits 0, prints no autopilot runs recorded yet, and still writes a calibration.json with every class at n: 0 and every statistic null — so a caller can always read the file rather than branch on "does it exist yet". windowTokens comes from CYBERINE_AUTOPILOT_WINDOW_TOKENS (default 1000000).

resolveEffortBoundaries() (src/autopilot/effortCalibration.ts) is exported for the future scheduling-policy consumer: it returns the measured { median, p90 } per class when that class has n >= 3, and falls back to DEFAULT_EFFORT_TABLE otherwise — S 40000/60000, M 120000/180000, L 300000/450000 median/p90 context tokens. That table is documented, not tuned: a starting point before this project has measured a single real autopilot run, not a number anyone should trust until calibration.json carries real samples.

Verify it:

npm run selftest:effort-calibration

Two fixture run directories (hand-written autopilot.jsonl) plus a fixture board pin the whole attribution chain: per-class n/median/p90 (with the n < 3 class reporting n/a), a task spanning two sessions summing correctly (and counting ONCE toward its class's n, not twice), an outlier declared far below its measured class, and calibration.json validating against a zod schema after a real write to a temp dir.

Known Limitations and Deferred Work. The command only reads history that already exists — it never drives a session itself; pairing it with sessionDriver.ts and stamping taskId from a [nf-tasks <id>] prompt prefix is a future autopilot command, not this one. resolveEffortBoundaries() is exported but nothing in the scheduler consumes it yet — that wiring, and any policy decision about what to DO with a flagged outlier (split the task? re-estimate it?), is deliberately out of scope here. The outlier check only looks at immediate-neighbour classes (S vs M, M vs L), never a direct S-vs-L comparison, and percentiles use nearest-rank (not linear interpolation), which is coarse at small sample counts — expect noisy boundaries until real history accumulates.

cyberine tasks <subcommand> — create, re-judge, reconcile and archive the board

Everything above is the READ half. The subcommands below provide the board view and WRITE half: a port of the deterministic core of the nf-tasks skill into src/tasks/board*.ts, so a machine that has cyberine but NOT the skill can create, re-judge, reconcile and archive the same board, and on a machine that has both, either tool picks up where the other left off. The format contract both sides conform to is docs/tasks-manifest.md.

Bare cyberine tasks is cyberine tasks status: it prints the board, and --all, --summary and --json pass through (k0zd1, 2026-09-30). cyberine tasks next is cyberine next. The legacy .local/dogfood/backlog-*.md listing moved to cyberine tasks dogfood [--all].

cyberine init creates .local/tasks/drafts/consumed/, including when a task manifest already exists.

Ids accept git-style unambiguous prefixes everywhere (kvg resolves to kvgex when it is the only match; an ambiguous prefix errors with the candidate list). Validation refusals exit 2; operational failures (lock busy, doctor findings) exit 1.

Run recipes:

# read the whole board; --all removes submodule path scope.
# The full view ends with a [board health] section (backlog-rot / age / stale-doing / recurring)
# when there is a finding.
cyberine tasks status
cyberine tasks status --all

# one stdout line for a SessionStart banner; scope notices and stale-doing warnings use stderr
cyberine tasks status --summary

# the whole board as one JSON line (schema board-status/1, see "JSON reads of the board");
# --all works as above, --summary does not combine with it (exit 2)
cyberine tasks status --json

# preview the highest-priority runnable tasks without taking the board lock or changing status
cyberine tasks run --dry --take 2

# atomically claim two tasks, then hand the printed prompts to workers
cyberine tasks run --take 2

# claim exactly these runnable ids; the whole call refuses if either is unavailable
cyberine tasks run k7m2q T03

# renew this session's claim leases (heartbeat) -- a live session re-stamps its holds so they
# are never reclaimed; reclaims any other session's expired claims first
cyberine tasks run --renew

# cluster the runnable pool into worker GROUPS for affinity-aware fan-out (ported from nf-tasks'
# `group`, krf5m): dependency components first, then merged into affinity clusters by explicit
# area: tag or >= 2 shared title/do: tokens, then bin-packed into K groups (default 5) ordered by
# priority (each group's own priority is its most urgent member). Read-only -- takes no lock.
cyberine tasks group
cyberine tasks group --workers 3
cyberine tasks group --json

# start a machine-local board, or create a tracked board in .claude/TASKS.md
cyberine tasks init
cyberine tasks init --sync

# move the manifest, briefs, archive, dedupe ledger and journal together
cyberine tasks relocate --to sync --dry
cyberine tasks relocate --to sync
cyberine tasks relocate --to local

# create a task -- --effort is MANDATORY (S|M|L); omitting it exits 2 and writes nothing
cyberine tasks add --title "Add rate limiter" \
  --do "Add a token-bucket rate limiter to the auth API." \
  --done-when "POST /login returns 429 after 5 reqs/10s from one IP." \
  --effort M --model sonnet --exec agent --priority p1 --area auth \
  --deps T03,k7m2q --why "bound login abuse" --brief-file ./notes.md

# draft a task the OPERATOR promotes later (kwzd6): --status accepts todo|backlog ONLY, so no new
# block can be created as doing, done or forbidden. --created-by records who drafted it.
cyberine tasks add --title "..." --do "..." --done-when "..." --effort S \
  --status backlog --created-by autopilot-brainstorm --why "USAGE.md Known Limitations under X"

# re-tier an existing task (any subset of the four tier flags; at least one required)
cyberine tasks set kvg --effort L --model opus --exec agent --priority p0

# per-task reasoning effort for a dispatched agent (kfvvf): low|medium|high|xhigh|max, exec: agent
# only, placed right after effort:. "-" removes it. add --reasoning and save's
# "reasoning" key write the same field. Known limitation: cyberine autopilot does not pass it to
# the driven session yet; it is a board field shared with nf-tasks until then.
cyberine tasks set kvg --reasoning high
cyberine tasks set kvg --reasoning -

# edit a live task's text. --do and --done-when narrow scope, so they REQUIRE --reason
# (a value is whitespace-collapsed onto ONE line, so a newline can never inject a field)
cyberine tasks set kvg --title "Add the rate limiter to the login API"
cyberine tasks set kvg --do "Add a token-bucket limiter, 5 reqs/10s." --reason "narrowed after audit"
cyberine tasks set kvg --done-when "POST /login returns 429 after 5 reqs/10s." --reason "made observable"
cyberine tasks set kvg --why "bound login abuse"
cyberine tasks set kvg --why -                                 # "-" removes the why: line
# --done-when also rewrites the brief's ## Done-when body (heading + comment kept); --do never
# rewrites the brief's Intent -- it only prints a stale-brief notice when a brief exists

# re-tier MANY tasks at once (ported from nf-tasks): all-or-nothing -- every entry is validated
# BEFORE anything is written, and one bad entry (unknown id, invalid field, exec/model mismatch,
# duplicate id) rejects the whole batch with every problem listed, manifest byte-identical.
# On success it is ONE atomic manifest write plus ONE journal event naming the count, the judge,
# and the id list -- never one event per entry, which would hide that these were one decision.
# --by is REQUIRED: a batch with no judge named is not an audit trail. Reuses the exact same
# per-field validation as the single `set` form above, so the two paths can never drift on what
# an exec/model/priority/effort/reasoning value is allowed to be.
cat > /tmp/batch.json <<'JSON'
{"changes": [
  {"id": "kvg", "model": "opus", "priority": "p0"},
  {"id": "k2x4", "exec": "manual", "model": "-"}
]}
JSON
cyberine tasks set --batch /tmp/batch.json --by claude-opus-5
echo '{"changes":[{"id":"kvg","reasoning":"high"}]}' | cyberine tasks set --batch - --by claude-opus-5
# Known limitation: batch entries carry only model/reasoning/exec/priority/effort -- title/do/
# done-when/why stay single-id-only (set <id> --title|--do|--done-when|--why), since those fields
# are not part of a re-tier decision and --do/--done-when's --reason requirement has no batch-wide
# analogue yet.

# flip status (an APPEND to the overlay -- no lock, never rewrites the manifest)
cyberine tasks status doing k7m2q
cyberine tasks status done k7m2q --evidence "typecheck 0, sweep 194/0"
cyberine tasks status todo k7m2q --fail "provider 402, retry when funded"
cyberine tasks status fail k7m2q "provider 402, retry when funded"  # also appends ## Attempts to the brief
cyberine tasks status doing k7m2q --path                 # stamp path: from this cwd if absent
cyberine tasks status done                              # close every effective [doing] task
cyberine tasks status forbidden k7m2q                    # explicit tombstone flip

# recurring cadence (R blocks in the board manifest)
cyberine tasks recurring list                            # STATE|id|days|every|last-done|title
cyberine tasks recurring due-count                       # count NEW and DUE blocks
cyberine tasks status did R02                             # set last-done: to today's local date

# recurring writers (R_FIELD_ORDER: every, last-done, exec, model, reasoning, do)
cyberine tasks recurring add "Run flow-dream on memory" --every 7d --do "Run flow-dream." --exec agent --model sonnet
cyberine tasks recurring set R02 --model haiku --reasoning high
cyberine tasks recurring set R02 --exec -                # "-" clears exec/model/reasoning/last-done (never every:/do:/title)
cyberine tasks recurring drop R02

# `run` also dispatches an OVERDUE recurring block that opted in with `exec:`, in its own trailing
# section after the task plan -- close it with `status did`, never `status done`. A block with no
# `exec:` stays surface-only (recurring list/due-count only) and never appears in `run`'s plan.
cyberine tasks run --dry                                 # includes a "## Recurring dispatch" preview
cyberine tasks run                                       # claims tasks AND marks overdue R-blocks dispatched
cyberine tasks status did R02                            # close it; a second `run` will not re-dispatch it

# blocker: field writers (a lock-guarded set/clear on the block, not a status flip)
cyberine tasks status block k7m2q "waiting on a third-party API key"
cyberine tasks status unblock k7m2q

# dependencies
cyberine tasks deps show k7m2q
cyberine tasks deps set k7m2q "T03,k9s4m"      # "-" clears to `deps: -`

# bake the overlay back onto the manifest (the rare, deliberate op)
cyberine tasks reconcile --dry-run              # reports what WOULD change, writes nothing
cyberine tasks reconcile

# move every [done] block into TASKS_ARCHIVE.md, briefs alongside
cyberine tasks archive                          # unconditional
cyberine tasks archive --auto                   # no-op below 10 done tasks

# one-shot end-of-session wrap: gate on pending drafts (exit 2, nothing written), reconcile the
# overlay, archive EVERY [done] task with no threshold (briefs move alongside), report anything
# still [doing], then print the resulting board
cyberine tasks end
cyberine tasks end --force                      # wrap anyway with drafts pending (they stay in the inbox)

# tombstone a task: bakes [forbidden] in place, writes reason: under the header, journals a note
cyberine tasks status forbidden k1abc --reason "drops the production database"

# read-only integrity report; exits 1 on a ghost-id gap in the LIVE manifest.
# section 8 (informational, never gates) lists [forbidden] blocks with no reason: and
# reason: lines left on a block that is no longer [forbidden]; section 10 (informational) lists
# open tasks short of the manifest standard: a do: of more than one sentence or over 300
# characters, and an effort M/L task with no brief
cyberine tasks doctor

# duplicate handling (writes TASKS_DEDUPE_LEDGER.jsonl next to the manifest)
cyberine tasks merge k9s4m k2p3t                # k9s4m becomes duplicate-of k2p3t
cyberine tasks merge --reject k9s4m k2p3t       # record "reviewed, NOT duplicates"

# remove live tasks you will not do: block + brief gone, ONE "dropped" journal note.
# Ids are EXACT (a bare prefix is refused with a did-you-mean -- unlike `status`/`set`/
# `deps`/`merge`, which accept an unambiguous prefix). Refuses [done] (archive it),
# [forbidden], archived ids, and ids a surviving task still depends on (or is duplicate-of).
cyberine tasks drop k9s4m --reason "superseded by k2p3t"
cyberine tasks drop k9s4m k2p3t                 # several ids at once
cyberine tasks drop k9s4m --dry                 # print the plan, change nothing

# erase EVERY trace of an id, live or archived: manifest/archive block, both brief
# copies, journal events, dedupe-ledger rows -- the only way to remove an archived,
# [done] or [forbidden] id. Nothing records that it happened.
cyberine tasks forget k9s4m
cyberine tasks forget k9s4m k2p3t               # several ids at once
cyberine tasks forget k9s4m --dry               # print the plan, change nothing

# append a dated update to a live task without touching its status or fields:
# `note` appends `update <YYYY-MM-DD>: <text>` below the manifest block's own fields (a
# non-field-shaped line every parser reads past); `brief note` appends `- <YYYY-MM-DD>: <text>`
# to the brief's `## Updates` section, creating it above the first heading the first time.
# Both accept an unambiguous id prefix, both journal a `note` event, and both refuse a
# recurring id and a [forbidden] tombstone; `brief note` also refuses a task with no brief.
cyberine tasks note k7m2q "re-scoped: only the read path is in for v1"
cyberine tasks brief note k7m2q "confirmed with ops: the write path ships in a follow-up"

# record work already done outside the task flow (born [done], straight into the archive)
cyberine tasks log --by claude-opus-5 "Fixed CORS on the gateway" "Bumped opencode 0.4"

# journal + timeline
cyberine tasks journal "Investigated the flaky CI job" --body "..." --id k7m2q
cyberine tasks journal --title "Big refactor day"
cyberine tasks timeline                         # month-grouped day list, newest first
cyberine tasks timeline 2026-09-06              # one day: done spine, decisions, notes
cyberine tasks timeline --grep "rate limit"

# machine-readable snapshot of every task, JSONL on stdout (for a dedupe pass)
cyberine tasks corpus --recent 30

# read-only: commits in a window whose message names no board task id (in any form: the
# scope, the subject or the body), plus the uncommitted paths. The window is a git ref
# (commits after it) or anything git log --since accepts; the default is the last 24 hours.
# Merges are excluded. /cyberine:end reads it to propose logging work that never reached the board.
cyberine tasks unlogged
cyberine tasks unlogged --since 65305c88 --json
cyberine tasks unlogged --since "3 days ago"

# render a READ-ONLY, self-contained static HTML snapshot under .local/tasks/board.html:
# a Kanban by effective status, a sortable table, and recurring/archive/journal tabs, with a
# search box and model/area/exec filters. No network requests; nothing on the page writes the
# board, and its statuses are the SAME derivation `cyberine tasks status` uses, so a flip not
# yet reconciled shows on it immediately.
cyberine tasks board
cyberine tasks board --open                       # also open it in the default browser
cyberine tasks board --out /tmp/board.html        # write somewhere else
cyberine tasks board --json                       # print the page's data as JSON, write nothing
cyberine tasks board --archive-recent 0 --journal-days 0   # smaller page (no archive/journal rows)

# convert a pre-convention board in place (timestamped backup first)
cyberine tasks migrate --dry
cyberine tasks migrate

# read-only: inspect board state (manifest counts, drafts, recurring-due, tidy candidates,
# the same board-health findings as `status`) and print the one recommended next command
cyberine tasks suggest

# mechanical housekeeping only: reconcile, refresh the header token-list comment, group every
# [forbidden] block at the end under a tombstone marker, and report (never auto-rewrite)
# old-style forbidden workarounds -- a [todo]/[backlog] task whose title/blocker/do: reads
# "FORBIDDEN"/"NEVER RUN"/"DO NOT" but was never flipped to [forbidden]
cyberine tasks tidy

# reorder one whole task block relative to another; refuses a recurring (R##) block, a
# [forbidden] tombstone, either as the block being moved OR as the anchor, and moving a task
# relative to itself
cyberine tasks move k7m2q --before k9s4m
cyberine tasks move k7m2q --after k9s4m

# the pure validator+writer: consume an already-decided batch. `save` and `inbox` are the
# documented verbs since k8acs; the older names `converge-apply` and `gather` still work as
# hidden aliases for scripts, and no refusal or reply names them.
cyberine tasks save --schema
cyberine tasks save --input batch.json
cat batch.json | cyberine tasks save
# every entry needs a six-section "brief" (kg2gf): a missing brief, or a bare N/A / None / TBD / -
# in any section, rejects the whole batch. decisions/rejected/gotchas take "None — <why>" instead.
# "exec" and "model" are optional (k4hwz): an entry without them gets exec: agent / model: sonnet,
# the same defaults as `add`, so a skill never has to pass them.

# leave a provenance-stamped draft in another repo's inbox, then read pending drafts there
cyberine tasks draft --to ../other-repo "Add rate limiter" --do "Limit login attempts" --done-when "Excess attempts return 429"
cd ../other-repo
cyberine tasks inbox
# copy each listed filename into its task's "draft" key in batch.json before applying
cyberine tasks save --input batch.json

Known Limitations and Deferred Work. Neither verb resolves an ARCHIVED id (moved out of the live manifest by archive/end) -- both refuse it the same way as an unknown id's sibling check, naming it archived rather than missing, and point back at note for a task that has no brief. Neither edits any of the brief's six converge sections or the manifest's own fields -- note/brief note are purely additive logs, never a substitute for set/deps set when the task itself changed. There is no note --list or per-task update history view yet; read the manifest block or the brief's ## Updates section directly, or cyberine tasks timeline for the journal's day-grouped view of the same events.

Set NF_TASKS_SYNC_DIR=.codex before cyberine tasks init --sync or relocate --to sync to use .codex/TASKS.md. A tracked board adds <syncDir>/journal/*.jsonl merge=union to .gitattributes; .local/ stays ignored. Relocation refuses pending status flips, competing manifests, and destination files it would overwrite. A .local/tasks/ACTIVE.md symlink that resolves to the tracked manifest (left by older nf-tasks setups) is an alias, not a competing board: relocate names it in the --dry plan as an unlink: line and removes it before moving the real file, and cyberine tasks doctor no longer reports it as a second manifest. relocate --to local runs no git command: when the source was tracked it prints a note: that the moved files now show as deleted, and the removal commit is left to the repo; it appends .local/ to .gitignore only when the new location is not already ignored. In plain words, /cyberine:tasks maps "untrack the board" / "đưa board ra khỏi git" to --to local and "track the board in git" to --to sync, dry-runs first and asks once. Run cyberine tasks reconcile before relocating a board with pending flips. cyberine tasks doctor exits 1 when two manifest candidates exist and reports which board hides the other.

Known Limitations and Deferred Work. Relocation does not merge competing boards or reconcile status flips automatically. Resolve both cases before moving the board.

Known Limitations and Deferred Work. cyberine tasks suggest is read-only and never writes anything, including the manifest, the overlay or the lock file; it always reports hook : not-applicable because cyberine has no SessionStart hook to detect (unlike upstream nf-tasks suggest, which checks for one). cyberine tasks tidy only does the mechanical half: it reconciles, refreshes the header token-list comment, and groups every [forbidden] block at the end under a tombstone marker (the neutral (board tidy) marker and Task board manifest v1 header line since kd2nb, so every writer puts the same bytes on a shared board and no board names a private tool; an older (... tasks tidy) marker is recognised and replaced, never duplicated, and an older nf-tasks manifest v1 header that already lists [forbidden] is left as it is); it reports old-style forbidden candidates (a [todo]/[backlog] task whose title/blocker:/do: reads FORBIDDEN/NEVER RUN/DO NOT) but never rewrites one — confirming and migrating a candidate to [forbidden] is a judgement call left to cyberine tasks status forbidden <id> plus hand edits to its fields, same as upstream. cyberine tasks move only relocates a whole task (T##/k####) block; it refuses to move a recurring (R##) block, a [forbidden] tombstone, or to anchor relative to either, and anchor resolution is exact-id-match only (no prefix support).

Flags, per subcommand:

SubcommandFlags
status (board view)--summary (one stdout line), --all (all paths when reading a superproject board from a submodule)
status <done|todo|doing|backlog|forbidden> [id...]--evidence <text> (only on done), --fail <reason> (only on todo), --reason <why> (only on forbidden), --path (only on doing); done with no ids closes every effective doing task
status fail <id> "<reason>"Returns to todo and appends a dated Attempts line to the brief
status did <Rxx>Sets the recurring block's last-done: to the local date
recurringlist or due-count
run--take N or exact <id...> selectors; --dry / --dry-run previews without locking or claiming
group--workers N (default 5, clamped to [1, component count]), --json
addrequired --title, --do, --done-when, --effort S|M|L; optional --model <tier> (default sonnet; forced to - when --exec manual), --exec agent|manual|opencode (default agent), --priority p0|p1|p2, --area <tags>, --deps <ids>, --why <text>, --brief-file <path>
set <id>any of --effort S|M|L, --model <tier>|-, --exec agent|manual|opencode, --priority p0|p1|p2, --title <text>, --do <text>, --done-when <text>, --why <text>|-; --reason <text> (REQUIRED with --do or --done-when)
depsshow <id>, set <id> "<ids|->"
archive--auto
end--force
reconcile--dry-run (alias --dry)
doctornone
merge<dup> <survivor>, or --reject <a> <b>
drop<id...>, --reason "<why>", --dry
forget<id...>, --dry
log--by <model>, then one or more quoted descriptions
journal"<title>" with --body <text> / --id <id>, or --title "<text>" for a day-title override
timeline[<YYYY-MM-DD>], --grep <pattern>
corpus--recent N (default 30; 0 drops the archive section)
board--out <file>, --open (--no-open is the default), --json, --archive-recent N (default 50; 0 for none), --journal-days N (default 14; 0 for none)
migrate--dry (alias --dry-run)
suggestnone
tidynone
move <id>--before <anchor-id> or --after <anchor-id> (exactly one required)
save--schema, --input <file|->
draftoptional --to <repo-path>, --do <text>, --done-when <text>, --why <text>, --by <model-id>, --file <path> or --stdin; required title
inboxnone

Known Limitations and Deferred Work. cyberine tasks status only reads the board: it does not auto-reconcile when the overlay reaches 200 lines. The full view ends with a [board health] section that announces findings only — dep-satisfied [backlog] rot (naming the status todo <id> promote for each), [todo]/[backlog] ages from a created: field, stale [doing] claims, and the recurring due/dispatchable/manual split. Each group prints only when it has a finding, so a healthy board prints no section, and it never promotes or flips anything — there is no health gate; cyberine tasks tidy is a separate, deliberately-invoked mechanical pass, never triggered automatically by status or suggest. --summary emits one banner line and leaves the detailed task list, recurring schedule, dependency reasons and the health section to the full view. A [doing] task has no measurable age after its overlay claim is reconciled away, so the stale warning only covers tasks with a remaining overlay timestamp; NF_TASKS_STALE_DOING_DAYS changes its default three-day threshold.

Known Limitations and Deferred Work. cyberine tasks group never claims or writes anything (read-only, same as run --dry); dispatching a group is still the caller's job (one long-lived session per group, doing that group's tasks in listed dependency order). Its area:/token affinity clustering does not consult path: submodule scope, so --all is implicit -- it groups the whole board even when run from inside a submodule. Priority ordering is per-GROUP (its most urgent member decides), never per-task inside a group, matching upstream: a p2 task riding along in an otherwise-p0 area group keeps its own p2 label and is never reordered ahead of a same-group p0 task by this command.

Known Limitations and Deferred Work. cyberine tasks board writes a STATIC snapshot, not a live view: the page is regenerated by hand and never refetches, and the effective statuses it embeds are read once with the same derivation cyberine tasks status uses — so a flip made after it was generated is not on it until the next run (a flip that is already pending appears immediately, unreconciled). It writes only its own HTML file (default .local/tasks/board.html; --out changes that) and touches no board, manifest, archive or overlay file. The per-card copy buttons only put a cyberine tasks status ... command on the clipboard — nothing on the page writes the board. There is no groups tab embedding cyberine tasks group's output (run it separately) and no live #/tasks dashboard view; the page is a plain file under .local/, so it works offline and needs no server.

What the writes actually touch:

  • run reads the effective board under the shared lock, orders runnable [todo] tasks by p0, p1, p2 and then manifest order, and writes [doing] flips to the overlay before releasing the lock. Missing priority means p1. An id selection is all-or-nothing. Its plan includes the task's model tier, exec kind, brief path when present, [cyberine-task <id>], and CYBERINE_TASKS_ROOT=<root> so a worker resolves the same board. NF_TASKS_ROOT remains a supported fallback pin. --dry reads without taking the lock and never writes. In the SAME pass (same lock, an id-selection refusal aborts both halves together) it also re-reads every recurring R-block: one currently DUE or NEW that carries exec: gets a trailing "## Recurring dispatch" section in the plan and, on a real (non---dry) run, an overlay line <id> dispatched -- a distinct token from todo/doing/done/backlog/forbidden that can never collide with a task flip and that reconcile can never bake into the manifest (its header-match regex only ever sees T/k-shape ids). A block already marked dispatched is skipped until status did <id> resets its cadence, so one overdue chore is dispatched exactly once per due cycle even across concurrent run invocations (same claim lock as the task side). A model: missing or invalid on an exec: agent block defaults to sonnet with a NOTE: line, same as the task side.
  • cyberine next shows the same priority order; cyberine next --dispatch <id> immediately launches one run and spends the configured provider or seat quota. cyberine tasks run only claims and prints a plan for sessions to execute. cyberine autopilot is the separate unattended board loop that selects, runs, and closes tasks. TUI /dispatch claims through the same locked path as tasks run before it launches.
  • add / save / set / deps / merge / drop / forget / archive / migrate / reconcile take a portable mkdir spinlock at <root>/.local/tasks/.reconcile.lock (5s wall-clock ceiling) and rewrite the manifest via a same-directory temp file plus rename. A status flip takes NO lock — it is an append, which is the whole point of the overlay.
  • archive writes TASKS_ARCHIVE.md FIRST, then the manifest, both atomically. A crash between the two leaves the task in BOTH files; recovery is always to delete the block from TASKS_ARCHIVE.md, never from the manifest.
  • Every locked transform counts task headers before and after (fence-aware) and REFUSES the write on a mismatch or on empty output, leaving the overlay intact.
  • A [forbidden] block is a tombstone: reconcile rewrites its bracket only when an explicit todo flip appears somewhere in the pass.
  • A block whose bracket is empty (## k1 []) has no status to rewrite, so reconcile leaves the whole block byte-identical (no completed:, no evidence:, nothing cleared), consumes the flip, and prints one stderr line per such id: cyberine tasks: reconcile discarded a done flip for k1 -- its header bracket is empty ([]), so there is no status to rewrite; repair the bracket and flip it again. No command writes []; it only comes from a hand-edit of the manifest.

Known limitations and deferred work:

  • run prints a plan; it does not start an agent or close a task, for either a task or a recurring block. A dry preview can become stale before a later claim, so rerun without --dry to reserve the current selection. --dry-with-mate and OpenCode-specific routing (upstream routes an exec: opencode block through the nf-opencode skill invocation shape) are deferred; run prints the same plain prompt/CYBERINE_TASKS_ROOT shape for every exec kind, task or recurring.

  • A claim carries a lease (ky0c9). Each run stamps a lease: <session>@<host> <expires-ms> field onto the claimed block and flips it to [doing]. The claim outlives neither its session nor the board: run reclaims any [doing] task whose lease expired (back to [todo], one journal line naming the dead owner) before selecting, and reconcile clears a lease: line the moment a task leaves [doing]. Renewal is the explicit run --renew heartbeat (any board write does NOT renew — a session doing long work with no intermediate write must call it). A status done/doing flip on a task you no longer hold is refused with the holder's name, never written over.

  • No LLM judgement anywhere in this surface. save is a validator and writer only: it consumes an already-decided JSON batch (--schema prints the shape) and makes no dedupe, triage or brief-writing judgement of its own. merge likewise records a verdict you already reached; it does no similarity scoring.

  • Drafts remain pending until named in a batch. inbox prints the from, draft, and optional path values to copy into an already-decided save batch. It does not decide whether to turn a draft into a task. An omitted draft key leaves that file pending; a misspelled key rejects the whole batch before writing.

  • Recurring writers and dispatch. recurring add/set/drop write R-blocks in R_FIELD_ORDER (every, last-done, exec, model, reasoning, do), matching upstream nf-tasks' validation (--reasoning only with exec: agent; model:/reasoning: only with exec: set; drop takes an exact id, never a prefix). run dispatches an overdue exec:-bearing block once per due cycle (see the run bullet above).

  • Task-quality warnings never refuse (k5n66). add and save print warning: <id>: ... on stderr for a new task whose do: reads as more than one sentence (terminators counted outside backticks, with e.g.-style abbreviations and version numbers ignored) or runs over 300 characters, and add also for an effort M/L task written without --brief-file; the task is still written and the exit code is unchanged. doctor's section 10 reports the same findings for every open task.

  • A forbidden flip writes reason: only with --reason. status forbidden <id> --reason "<why>" reconciles at once and writes reason: directly under the header (a second call replaces it); without the flag the tombstone lands with no reason and stderr says how to add one.

  • A failed attempt has two writes. The todo overlay flip lands first; if the brief write fails, the command reports that partial result and exits nonzero. The brief can be missing and is created when the attempt is recorded.

  • add cannot set path: (only save can), and add writes a brief file only when --brief-file is given — a missing brief is reported by doctor as informational, never fatal. That file is copied VERBATIM to tasks/<id>.md; it is not folded into the six-section template save renders, because inventing placeholder sections would put prose on disk nobody wrote. A brief with no ## Decisions / ## Rejected approaches heading simply yields no journal decided event at reconcile time.

  • archive does not stamp completed: on a normally-archived block; only log's born-done blocks carry it, matching the skill.

  • end is a wrap, not judgement. It takes the board lock for the reconcile+archive pair and releases it before printing. Inside that pair it archives EVERY [done] task with NO threshold — unlike archive --auto, which is a deliberate no-op below 10 — and moves each archived task's brief alongside it. It REPORTS every task still [doing] at session end but never flips one: whether a doing task actually finished is a judgement the command will not make. A pending draft in .local/tasks/drafts/ refuses the whole wrap (exit 2, nothing written); end --force wraps anyway and leaves the drafts for a later converge. Re-running on a clean board is idempotent — it reports no [done] tasks to archive. and reprints the unchanged board.

  • drop and forget are the two removal verbs — there is no move. Dropping a live task deletes its manifest block and its tasks/<id>.md brief and appends ONE dropped <id>: <title> journal note (--reason becomes its body); earlier journal history, TASKS_ARCHIVE.md and TASKS_DEDUPE_LEDGER.jsonl are left alone, so a dropped id still leaves traces in those. It refuses [done] (archive it instead), [forbidden] (a tombstone), archived ids, an id a surviving task still lists in deps:, and one a surviving task is duplicate-of. Ids are EXACT: a bare prefix is refused with a did-you-mean, unlike status, set, deps and merge, which accept an unambiguous prefix. --dry prints the plan and writes nothing. move (reorder a block) is not ported.

  • forget is the only way to remove an archived, [done] or [forbidden] id, and it leaves no trace at all. It erases the manifest block AND the TASKS_ARCHIVE.md block, both brief copies (tasks/<id>.md and tasks/archive/<id>.md), every journal event carrying the id (rewriting the day's .jsonl file, or deleting it when the id was the only event left), and every TASKS_DEDUPE_LEDGER.jsonl row naming the id — unlike drop, it records no note anywhere. It still works on an id whose manifest block is already gone but whose brief or journal events remain, and it shares drop's dependency/duplicate-of guard against a surviving task. Ids are EXACT, same as drop. It refuses an id with literally no trace on the board (no trace of this id) rather than silently doing nothing. --dry prints the plan and writes nothing.

  • An unknown or empty bracket stays invisible. [dne] or [] is parsed, warned about by cyberine next and reported by cyberine tasks doctor, but stays out of next, /tasks and autopilot until it is hand-fixed; [done 2026-08-10] reads as its first token. nf-tasks refuses both the same way since 2026-09-20, so the two tools now derive the same runnable set.

  • A flip against an empty bracket is HELD, not discarded (kc7x6). Reconcile cannot rewrite a [] header, so instead of dropping the flip it re-publishes it verbatim as a fresh status.d file — original epoch, agent tag and extras intact — prints reconcile is holding a <status> flip for <id>, and leaves it out of the journal harvest. It survives any number of reconciles as exactly one retained line. Repair the bracket and the next reconcile bakes it with the date it ORIGINALLY happened, evidence included; nothing has to be re-flipped. cyberine tasks doctor section 9 lists every held flip with its age:

    9) Held flips (informational -- never fails this report)
      k2abc -- held "done" flip, 5d old (2025-09-04T...)

    Known Limitations and Deferred Work. A held flip is only visible through doctor — nothing reminds you at flip time, and nothing expires it, so a bracket never repaired holds forever. This is where the two implementations DIVERGE: nf-tasks still discards (measured 2026-09-21 on the same fixture, nf-tasks: reconcile discarded a done flip), so a board reconciled by nf-tasks loses the flip that Cyberine would have kept. That is deliberate — the two tools are not reconciled with each other — but it means the holding guarantee is Cyberine's alone.

  • set journals one note per call that changed anything. --title, --why, --do/--done-when (the latter two carrying their required --reason) and, since kg2gf (2026-09-26, matching nf-tasks), --exec/--model/--effort/--reasoning/--priority append a single note whose title lists the changed fields and whose body carries old:/new: per field. An absent priority: is journalled as p1, its implicit value, and a flag that sets a field to the value it already has journals nothing. Every value is whitespace-collapsed onto one line, so a newline can never inject a field or a header.

  • --do / --done-when are scope cuts and REQUIRE --reason. --done-when also rewrites the brief's ## Done-when section body (the heading and its <!-- ... --> comment stay); it updates the manifest only, with a stderr notice, when the task has no brief or the brief has no ## Done-when heading. --do NEVER rewrites the brief's Intent — a mechanical rewrite would corrupt broader prose — and instead prints a stale-brief notice when a brief exists. --why - removes the why: line.

  • Re-applying an old save decision after a --title/--do edit can mint a duplicate. convergeApply's skip-check is the title+do pair, so once set changes either half the original decision no longer matches the block and a fresh id is minted instead of being skipped.

  • set has no --batch. A rubric re-tier of many tasks is N independent locked calls, with no single-write/single-journal-event batch form here (dropped by kqfjh).

  • No test framework here either. npm run selftest:tasks-write and npm run selftest:tasks-lock are the regression guards; both use throwaway repositories under the OS tmpdir and never touch this repo's own .local/tasks/ board.

Interop with the nf-tasks skill (TASKS-INTEROP-SMOKE). A board written by either implementation reads correctly in the other — npm run selftest:tasks-interop is the regression guard, in two halves. Half one (always runs, no external dependency) drives cyberine's own reader (readManifest / computeRunnable) and CLI (doctor, reconcile --dry-run, migrate --dry) against real-file fixtures under scripts/fixtures/tasks-interop/: a board exercising k-id/T-id/R-id parsing, field-order independence (created-by:/why: before do:), a fenced code block whose embedded ## id [status] title line must never count as a task, the v1+v2 overlay replayed by real filesystem mtime rather than filename order, the duplicate-of tombstone + survivor dependency resolution, a legacy (pre-convention) board, and a non-board file. Half two additionally drives the real Claude Code nf-tasks skill via bun: cyberine add/status done/archive/logs a board, nf-tasks status/doctor reads it clean, then nf-tasks log writes a born-done task and cyberine's own doctor reads that clean too. Half three (always runs) replays FROZEN nf-tasks output captured under scripts/fixtures/tasks-interop/nf-frozen/ -- a reconcile, a converge block, a run --dry set, and a whole board nf-tasks wrote (overlay, archive, journal, draft, briefs) -- and asserts cyberine reads and reproduces it, so the cross-tool contract stays checked on a machine with no bun or nf-tasks (the CI runner, or after nf-tasks is retired). Where both exist, half two runs too and also checks the frozen files still match the live nf-tasks; a mismatch fails with the recapture command, TASKS_INTEROP_CAPTURE=1 npm run selftest:tasks-interop. Known limitation: the frozen board is one capture of nf-tasks at the sha recorded in nf-frozen/meta.json; a behaviour nf-tasks gains later is covered only after a recapture.

/cyberine:tasks — the plugin skill that supplies the judgement these commands refuse to make (k7h49)

Everything above is deterministic on purpose: save consumes an already-decided batch, merge records a verdict you already reached, and nothing scores similarity or writes a brief for you. /cyberine:tasks is the bundled Claude Code skill that supplies exactly that missing half, so a user with the plugin and no nf-tasks install still has a verb for "turn what we just discussed into tasks". It decides — dedupe, split, dependency order, a brief per task, what counts as evidence — then writes ONLY through the subcommands above; it never edits the manifest, the overlay, the journal or the archive directly.

Run recipe. The slash form below always works; since kfp69 (2026-09-29) the skill is also model-invocable, so in a repo that has a board, plain words with no "cyberine" in them ("what's next", "task nao tiep", "xong roi cai rate limit", "save these as tasks") reach it too. In a repo with no board, plain words do not; type /cyberine:tasks there and it offers to start one.

# in a Claude Code session with the plugin installed
/cyberine:tasks                                  # READ-ONLY: board snapshot, then the next task and why
/cyberine:tasks save these as tasks              # converge whatever the conversation just decided
/cyberine:tasks next                             # the same read, stated: pick one and explain the pick
/cyberine:tasks done k7m2q                       # close it with observed evidence
/cyberine:tasks block k7m2q "waiting on the Stripe live key"
/cyberine:tasks tidy                             # reconcile + archive --auto + doctor
/cyberine:tasks end                              # cyberine tasks end: gate on drafts, reconcile, archive all done, print the board

The intent map it follows, and the command each intent lands on:

IntentCommand it calls
"save these as tasks", "gom plan thành tasks", "turn this into a backlog"cyberine tasks corpus to dedupe, then cyberine tasks save --input - with briefs in the batch
"what's next", "task nào tiếp"cyberine next, then cyberine tasks corpus for the picked task's body
"done", "close X", "xong rồi"cyberine tasks status done <id> --evidence "<what was observed>"
"block this on X", "chờ credential"cyberine tasks status block <id> "<reason>" / status unblock <id> (k3c00)
"tidy the board", "archive done"cyberine tasks reconcile, archive --auto, doctor
"add a recurring task", "every week remind me to X", "việc định kỳ"cyberine tasks recurring add "<title>" --every <N>d|w|m --do <text>; status did <Rxx> when one is done
"add one task"cyberine tasks add --title ... --do ... --done-when ... --effort ... --brief-file <path>
"wrap the board", /cyberine:tasks endcyberine tasks end (add --force only while drafts are pending)

It is the cheap counterpart to /cyberine:plan: plan INVENTS a list nobody has yet and pays a full fleet round plus adversarial skeptics to expose the gaps; this skill CONVERGES a list the session already holds, in one turn, for the price of the conversation that produced it.

Known Limitations and Deferred Work. No status forbidden. Recurring chores are cyberine tasks recurring add|set|drop|list and status did <Rxx>; the skill routes "add a recurring task" / "every week remind me to X" there. The drafts inbox is available through cyberine tasks draft --to, inbox, and the draft key in save; the skill still has to decide which gathered drafts become tasks. Dedupe is the skill's own judgement: the only automatic check is save's exact (title, do) idempotence skip, so a re-worded near-duplicate lands as a second task and is repaired afterwards with cyberine tasks merge <dup> <survivor>. save only CREATES, so the skill cannot update an existing task through it — retiering is set, deps are deps set, and a blocker: on an existing task uses status block / unblock. Its claude plugin eval coverage is the nl-* suite's board intents (nl-next-*, nl-capture-*, nl-board-*, nl-neg-noboard-*), and as of 2026-09-13 it has never converged a real conversation on a user board that is not the author's own. The bare invocation is a read that writes nothing, so the frontmatter description and argument-hint name intents (save, what's next, close, block, wrap) and never a CLI verb - the verb list is the skill body and cyberine tasks --help; a bare call that turned into a write without the operator's go-ahead would be a bug, not a feature. Plain-language triggering (kfp69) is designed, not measured: the phrase set was measured firing the standalone cyberine-taskboard plugin's skill with claude -p on Sonnet, but for this plugin's tasks skill trigger reliability is unmeasured until the operator runs the nl-* suite (plugin-evals/, operator-gated because it spends quota), in particular nl-board-* (fires in a repo with a board) and nl-neg-noboard-* (stays silent without one). The no-board check is wording in the skill (look for .local/tasks/ACTIVE.md or .claude/TASKS.md before any cyberine command), not a filesystem gate the harness enforces, and the model decides whether to invoke the skill before it has looked.

Task-boundary policy — the three tiers that decide whether to take another task (knrwd)

src/autopilot/boundaryPolicy.ts answers one question: now that a task has just finished, does this session take another one? It is decided at the TASK BOUNDARY, never at a token threshold. A fixed percentage cuts a session in the middle of a module and pays the bootstrap cost twice; when 36 percent of the window is what finishes the module, finishing it is right. So the context-fill number is an INPUT to a rule evaluated only at a boundary, not a trigger of its own. The cost of a candidate task comes from its declared effort: S|M|L priced through cyberine tasks calibrate (above) — calibration.classes[effort].p90 once that class has n >= 3 samples, DEFAULT_EFFORT_TABLE[effort].p90 before that; the returned reason string always names which of the two it used (calibrated or default table).

Three tiers, each a fraction of the context window:

TierDefaultEnv varMeaning
soft0.35CYBERINE_AUTOPILOT_SOFTDo not START a task that will not fit. A SMALLER task that does fit may still be taken — below soft the first task in order is taken regardless of fit, between soft and hard the first task that fits is taken
hard0.65CYBERINE_AUTOPILOT_HARDFinish the task in hand, then hand off. Take nothing new
emergency0.85CYBERINE_AUTOPILOT_EMERGENCYHand off NOW, even mid-task. A safety net, not an operating mode

soft < hard < emergency is validated at config load, and each value must be a fraction strictly inside (0, 1) — a percentage (35) or a 1 is rejected with a message naming the variable, not silently accepted. The emergency tier is only acceptable because this repo's vibe rule requires a .local/vibe/<task>.md goal file with milestone checkboxes, so a mid-task cut resumes from the last ticked milestone rather than from nothing.

Ordering is priority (p0 before p1 before p2; an absent priority counts as p1) then the task's line number in the manifest. Budget is windowTokens - contextTokens - CYBERINE_AUTOPILOT_HANDOFF_RESERVE, so a session always keeps enough room to write its handoff. Three filters run before any of that, each recorded in the returned excluded list: exec other than agent (not-agent), a task already started in CYBERINE_AUTOPILOT_REPEAT_FAIL sessions (repeat-fail, which also raises a repeat-fail:<id> lint), and the task that just finished (just-finished). A task with no effort: is sized as M and raises a missing-effort:<id> lint every time — the board tool requires effort at draft time, so its absence is a board defect worth surfacing rather than swallowing.

Extend recipe. decide(input) is a pure function: no I/O, no clock, no env read, no randomness. Every number it needs is passed in, so a caller resolves the config once (resolvePolicyConfig(process.env), or just read config.autopilotSoft and friends), maps the board with toPolicyTasks(readManifest(cwd)) (which carries effort, priority, exec and sets fileOrder from the manifest line), and passes the measured contextTokens from its own session driver. The verdict is one of continue (with nextTaskId), finish-then-handoff, handoff-now or no-runnable.

Verify it:

npm run selftest:boundary-policy

138 checks over a literal case table — every tier boundary, both estimate sources, each exclusion filter and its lint, priority-then-file ordering, the pick-a-smaller-task-that-fits behaviour, the config validation errors, and a determinism check that calls decide() twice on a deep-cloned input.

Known Limitations and Deferred Work. cyberine autopilot (see "Autopilot command") is the only caller that feeds decide() a live contextTokens reading and acts on the verdict; this module itself is still just the rule plus its guard. The history map (sessionsStarted per task) is an input the caller must supply; this module neither reads nor writes any history store. exec is matched strictly against agent, so a task with NO exec: line at all is excluded as not-agent rather than assumed runnable — the board tool stamps exec: on every task it creates, but a hand-written block without it will be skipped. Estimates are per-class p90s, so they are a scheduling heuristic and not a prediction for any individual task; and finish-then-handoff is returned (rather than handoff-now) even when the caller asks at a boundary with the task already finished, which keeps the two situations distinguishable in a log at the cost of the caller having to know they mean the same thing there.

Handoff bridge — hand an autopilot session off through the handoff writer, then bootstrap the next one (kcs40)

src/autopilot/handoffBridge.ts and src/autopilot/prompts.ts are the machine version of the user's manual loop: task ends, a handoff file is written into the handoff folder, a Telegram alert fires, a new Claude Code session reads the handoff plus the board and continues. The writer is always the builtin bin/cyberine-handoff.mjs (see "Builtin handoff writer" below), or whatever CYBERINE_HANDOFF_WRITER_BIN points at; since 2026-09-30 Cyberine no longer calls the author's private amemoria tool for handoffs at all (amemoria is memory-only). handoff(input) resolves the handoff folder via <writer> resolve (parsing its path= line — never hardcoded), snapshots the handoff files already there for this project, spawns <writer> offload --backend <backend> DETACHED with the seed on stdin and immediately unref()s it, then waits on EVIDENCE — a new handoff-<slug>_<project>.md file appearing in that folder, filtered by project suffix so other repos' concurrent sessions are ignored, and read as done once its size is stable across two polls (the writer may still be flushing). It never waits on a clock: fs.watch on the folder plus a pollIntervalMs poll (default 5000ms, since fs.watch alone can miss events on macOS) drive the same check, and a writer that never lands rejects with HandoffTimeoutError (carrying memoryDir and waitedMs) after timeoutMs (default 600000ms). buildSeed(input) is pure and produces the 2-line Where: ... / Next: ... seed <writer> offload reads on stdin, truncating the excerpt of the session's last result text and stripping any line that looks like KEY=value or names sk-/an api key/token/secret/password with a value, so nothing the driven session read from .env rides along.

prompts.ts builds the bootstrap prompt for the next session as a pure function of its inputs (buildBootstrapPrompt), never string concatenation scattered around: it tells the agent to read the handoff file at the exact resolved path (when one was produced), read the board via cyberine tasks and cyberine tasks doctor, work exactly the named task A-Z under the repo's own auto-loading rules, flip status ONLY with cyberine tasks status done <id> --evidence "<one line>" and file follow-ups only with cyberine tasks add ... --effort S|M|L (never hand-edit the manifest), end with one line DONE <taskId> when finished, or reply with the single word NO_RUNNABLE when the board has no exec:agent runnable task. buildContinuePrompt is the shorter version for later turns in the same driven session. PROMPT_VERSION (bump when the template's wording changes) is stamped into the bootstrap prompt so a transcript shows which template produced it. hasNoRunnableSentinel(text) matches the sentinel as a whole word, so NO_RUNNABLE_YET does not false-positive.

Run recipe — wiring one turn's result into a handoff, then bootstrapping the next session's first turn:

import { handoff } from './src/autopilot/handoffBridge.ts';
import { buildBootstrapPrompt } from './src/autopilot/prompts.ts';
import { resolveBoard } from './src/tasks/boardPaths.ts';

const { handoffPath } = await handoff({
  cwd: repoRoot,
  lastResultText: turn.resultText,
  branch: currentBranch,
  dirty: treeIsDirty,
  currentTaskId: 'kcs40',
});

const board = resolveBoard(repoRoot);
const prompt = buildBootstrapPrompt({
  handoffPath,
  taskId: nextTaskId,
  boardPath: board.manifest,
  repoRoot,
});

Extend recipe. writerBin (or CYBERINE_HANDOFF_WRITER_BIN, read by handoffWriterBinPath() in src/autopilot/handoffBridge.ts) points at a different writer binary that speaks the same resolve / offload protocol — useful for a per-machine install path or a test double; unset, the builtin bin/cyberine-handoff.mjs is used. backend (claude | codex | opencode, default claude) and model pass straight through to offload, where the builtin writer accepts and ignores them. pollIntervalMs trades wake-ups for responsiveness on a slow disk or a very fast writer.

Verify it:

npm run selftest:handoff-bridge

A stub writer script stands in for the real binary (resolve prints a temp folder, offload reads the seed from stdin to a file and writes the handoff after a short delay): asserts a successful resolve, a pre-existing handoff file is never returned, a same-poll-cycle file from another project's suffix is ignored, a writer that never lands times out with waitedMs at or above the deadline, and buildSeed strips a secret-shaped line. Positive control: skipping the pre-existing-file snapshot makes the very first poll return that stale file instead of waiting for the real one, and 6 assertions go red (including the timeout case, which then wrongly "succeeds" on the stale file) — restoring the snapshot turns the smoke green again.

Known Limitations and Deferred Work. backend defaults to claude, whose own default model is opus — this module never passes --model unless the caller sets one. The bridge waits on a FILE, so a writer that crashes, or one that succeeds but writes to a different project's suffix by mistake, surfaces identically as a plain timeout — a writer that CRASHES surfaces as a plain timeout, and one that succeeds but writes to a different project's suffix does too — the one refusal now caught early is a non-zero exit from offload with no candidate file on disk, which rejects at once with HandoffOffloadError rather than waiting out the deadline. cyberine autopilot (see "Autopilot command") is what calls handoff() and buildBootstrapPrompt() at a real task boundary. handoffWriterBinPath() (formerly amemoriaBinPath(), renamed 2026-09-30) returns CYBERINE_HANDOFF_WRITER_BIN when set and the builtin writer otherwise; there is no amemoria probe any more (see "Builtin handoff writer" below).

Builtin handoff writer — cyberine handoff, the only autopilot handoff writer (k73tg)

cyberine autopilot used to refuse to start unless the author's private amemoria skill binary (~/.claude/skills/amemoria/bin/amemoria) was executable, then (k73tg) preferred it when present and fell back to a builtin writer. Since 2026-09-30 (commits 3ab734c2, 61c3c2d4, 87c1e488) amemoria is memory-only and Cyberine never calls it for handoffs: the CYBERINE_AMEMORIA_BIN override and the skill-directory probe are gone. src/commands/handoff.ts ships the builtin, deterministic writer that speaks the two-verb protocol the bridge drives, and handoffWriterBinPath() in src/autopilot/handoffBridge.ts resolves it: CYBERINE_HANDOFF_WRITER_BIN (explicit override, for a test double or a different writer) wins, otherwise bin/cyberine-handoff.mjs (which forwards into cyberine handoff <args>, resolved through packageRoot() so it keeps working both from a repo checkout and from an installed package after cyberine build). No model call, no summarisation — the seed is written back near verbatim under a small YAML frontmatter block.

  • cyberine handoff resolve — prints path=<dir> for $CYBERINE_HOME/handoffs (created if missing) and exits 0.
  • cyberine handoff offload --backend <b> [--model <m>] — reads a seed from stdin (--backend/--model are accepted for protocol compatibility and otherwise ignored, since the builtin writer is deterministic), writes <dir>/handoff-<YYYYMMDD>-<HHMMSS>_<project>.md (project = the git toplevel basename of the invoking cwd) with a writer: cyberine-builtin frontmatter block followed by the seed verbatim, prints log: <path> to a small log file next to it, and exits 0. The filename satisfies the same handoff-.+_<project>\.md pattern and 8-digit-date requirement pickNewestHandoff (src/store/handoffPick.ts) already ranks on, and the frontmatter alone satisfies isHandoffContentComplete.
  • cyberine handoff path — prints dir=<dir> and file=<name> (the filename offload would use right now), so an interactive session can write a richer handoff by hand instead of piping a seed through offload.

Run recipe:

echo -e "Where: branch main (clean tree) in $(pwd)\nNext: pick up the next runnable task" | cyberine handoff offload --backend claude
cyberine handoff resolve
cyberine handoff path

Verify it:

npm run selftest:handoff-bridge

The same kcs40-handoff-bridge-smoke.ts smoke covers it: a case runs the real bin/cyberine-handoff.mjs (no stub) against a temp CYBERINE_HOME, asserts the written file matches the project pattern, satisfies isHandoffContentComplete, that findNewestHandoff resolves it, and that cyberine handoff path reports the same directory; a second case asserts handoffWriterBinPath() resolves to the builtin when CYBERINE_HANDOFF_WRITER_BIN is unset and to the override when it is set.

Known Limitations and Deferred Work. Deterministic and seed-only: no model call, no summarisation, no read of the session transcript the way the amemoria skill's own offload did — the handoff is exactly the seed the caller already built (buildSeed()), wrapped in frontmatter. The builtin is the only writer unless CYBERINE_HANDOFF_WRITER_BIN names another. --backend/--model are accepted (so scripts that always pass them keep working) but have no effect on the builtin's output.

Session handoff — cyberine handoff write --mode short|long (k81j7)

The end-of-session handoff a live session writes so the next session of the SAME repo resumes without re-discovery. Two modes, sharing one seed the live session composes from what it already holds (Where: / Decisions: / Open: / Next: lines on stdin; a bare line continues the previous section):

  • short (default, free): writes the per-session breadcrumb <repo>/.cyberine/handoffs/<session-id>.md straight from the seed, no model call. Sections: ## Where, ## Decisions, ## Open, ## Next (numbered), ## Context.
  • long: writes the same breadcrumb first, then starts a detached claude -p (the official CLI under your own login; default model opus, --model overrides) that reads the seed, a tail excerpt of this session's transcript and read-only git, writes the full handoff to ~/.cyberine/handoffs/handoff-<YYYYMMDD>-<HHMMSS>_<project>.md (Where, Done, Decisions, Open, Next, Watchouts, Key paths, Resume) and rewrites the breadcrumb with a ## Full pointer to it. The command returns at once; the worker's output goes to <repo>/.cyberine/handoffs/logs/. The worker may use only Read, Write, Glob, Grep, read-only git and gh run list / gh run view, with write access limited to the two handoff directories, and it runs without the parent session's CLAUDE_CODE_* variables and without ANTHROPIC_API_KEY when a subscription login exists. Since 2026-09-30 it also runs with --settings '{"disableAllHooks":true}', so the headless writer no longer fires your hooks or any plugin's hooks (the board banner, the handoff pointer and so on would otherwise run inside it).

The seed is trusted for what only the live session knew (decisions, intent, what was tried); live state is re-read by the worker instead (kct4c): branch and HEAD, commits ahead of and behind upstream, the dirty tree, and the CI result of the pushed head through gh run list. When the live value disagrees with the seed, the handoff carries the live value and says so; when a check cannot run (no upstream, gh not authenticated, no network) it says the status is unknown rather than repeating the seed. The prompt also asks for what the operator granted the session and what it spent, attempts that failed and why, side effects outside the repository, commands left for the operator, and a one-line resume hint.

--backend codex (k9bnn, long mode only) hands the same prompt to codex exec instead of claude -p, for when a ChatGPT plan is the cheaper writer. The worker runs with --cd set to the breadcrumb directory and Codex's own workspace-write sandbox, --add-dir for ~/.cyberine/handoffs/, /tmp and $TMPDIR excluded, network on (for gh), and approval_policy="never", so it can read the repository but write only to the two handoff directories; OPENAI_API_KEY is dropped from its environment when a ChatGPT login exists. Its model is Codex's configured default (~/.codex/config.toml) unless --model names one; claude stays the default backend and keeps opus. cyberine tasks end --handoff long --backend codex passes it through. --backend with --mode short, or with a value other than claude or codex, is refused with exit 2 before anything is written.

The session id comes from --session <id>, else CLAUDE_CODE_SESSION_ID (set inside a Claude Code session), else the newest transcript for this repo, else manual-<timestamp>; one file per session, so parallel sessions never overwrite each other. Breadcrumbs older than 14 days are pruned on each write. The first write creates .cyberine/handoffs/.gitignore containing *, so a handoff can never be committed even in a repo that never ran cyberine init.

Run recipe:

printf 'Where: main at abc1234, clean\nDecisions: X instead of Y -- because Z\nNext: build the pull verb\n' | cyberine handoff write
printf 'Where: ...\nNext: ...\n' | cyberine handoff write --mode long              # background claude, opus
printf 'Where: ...\nNext: ...\n' | cyberine handoff write --mode long --dry-run    # print the argv, spend nothing
printf 'Where: ...\nNext: ...\n' | cyberine handoff write --mode long --backend codex   # background codex exec, codex's default model

Verify it: npm run selftest:handoff-write (seed parsing and the breadcrumb byte-for-byte, the self-ignoring .gitignore, the 14-day prune, every refusal, the long-mode argv from --dry-run, a real detached spawn against a fake claude with the scrubbed env, the transcript excerpt, and offload unchanged).

Known Limitations and Deferred Work. Refusals exit 2 and write nothing: no seed, a TTY on stdin, an unknown mode or flag, --model with --mode short, a session id that is not a plain file-name token. The short breadcrumb always stands. long now tells you when its worker finished or failed (see "Long handoff completion notice" below); a writer that cannot even start (claude not on PATH) is reported by that notice rather than on the command's own stderr, because the command has returned by then. offload is unchanged: it is the deterministic writer autopilot always uses (since 2026-09-30 the only one), and cyberine autopilot bootstraps a cold start from ~/.cyberine/handoffs/ only, so a short breadcrumb alone is not what it reads (use long before an unattended run).

Long handoff completion notice (k5m91). --mode long (and so cyberine tasks end --handoff long and /cyberine:end) no longer fires the writer and forgets it. The command starts one detached watcher process (src/cyberine-handoff-watch.ts, logic in src/handoff/handoffNotify.ts) that runs the claude -p or codex exec writer itself, waits for it, and tells you when it stops, so closing the terminal or the Claude Code session does not lose the answer.

  • OS notification, on by default: a Handoff started · <HH:MM> banner when the writer begins (click opens the writer's log) and one when it exits, through the same src/tui/notify.ts code the fleet TUI uses (macOS, Linux notify-send; silent no-op elsewhere). The subtitle is the Claude Code session name (the transcript's last /rename title, else its AI title, else session <short id>), with the repo appended when the name does not already contain it. Success reads Handoff ready · ended <HH:MM> with the handoff topic and the duration in the body and opens the handoff file on click; failure reads Handoff failed · ended <HH:MM> with the exit, reason and duration and opens the writer's log. Success means exit 0 AND a non-empty handoff file at the announced path; exit 0 with no file counts as a failure. The two banners are never coalesced, so a writer that fails within two seconds still shows its failure banner (kmz1t).
  • Branded banners on macOS (kmz1t): npm run notifier:build compiles native/macos-notifier/main.swift into an ad-hoc-signed Cyberine.app under $CYBERINE_HOME/notifier/ with the Cyberine icon (needs Xcode's swiftc). When that app exists every OS notification goes through it, with the icon, a subtitle, a sound and click-to-open; without it macOS falls back to osascript (Script Editor icon, no click action). The first banner from the app asks for notification permission once.
  • Telegram, opt-in: with CYBERINE_TELEGRAM_BOT_TOKEN and CYBERINE_TELEGRAM_CHAT_ID set (shell or ~/.cyberine/.env; optional CYBERINE_TELEGRAM_THREAD_ID for a forum topic) the watcher also posts a START message when the writer begins and an END or END FAILED message when it stops, through the same resolveTelegramConfig + buildTelegramSendMessage channel cyberine autopilot uses. With no Telegram variables nothing is sent and nothing is logged about it. The OS banners mirror START and END.
  • Message layout (plain text): line 1 Handoff started|ready|FAILED — <session name>. START then reads Project: <repo>, Start: <HH:MM:SS> · <date>, Writer: <claude|codex> · session <short id>. END adds Topic: <topic> (the # Handoff — <topic> heading the writer produced, humanized) before Project:, then End: <HH:MM:SS> · <date> (took <duration>) and File: <path>. FAILED reads Project:, End: ... (after <duration>), Reason: <exit code or signal, with the reason when the writer exited 0 without a file or could not start> and Log: <path>.
  • Environment: the writer keeps exactly the scrubbed environment it always had (no CLAUDECODE or CLAUDE_CODE_*, no metered key when a subscription login exists), and now also loses CYBERINE_TELEGRAM_BOT_TOKEN. The watcher itself runs with a minimal environment: PATH, HOME, locale and display variables, and the Telegram variables only when both the token and the chat id are set; it never sees a provider API key. The writer's environment reaches the watcher over a pipe, never argv or a file. The bot token is never logged and never appears in a URL that is logged.
printf 'Where: ...\nNext: ...\n' | cyberine tasks end --handoff long        # banner (and Telegram, if configured) when the writer stops
CYBERINE_NOTIFY=0 cyberine tasks end --handoff long < seed.txt              # no OS banner; Telegram still sent if configured

Turn the OS banner off with CYBERINE_NOTIFY=0 or CYBERINE_NOTIFICATIONS=off; any other CYBERINE_NOTIFICATIONS value (including the bell default) gives the banner here, since a terminal bell means nothing to a process that has no terminal. The watcher's own stdout and stderr go to the same logs/long-<session>-<time>.log file as the writer's.

Verify it: npm run selftest:handoff-notify (no network: the watcher environment carries no API key and Telegram only when configured, the message layout for START, END and END FAILED, success and failure selection including exit 0 with no file, and a real child exiting 3 and a missing binary reported as failures). Operator check: run a real cyberine tasks end --handoff long and expect a Handoff started banner, then one naming the handoff file; force a failure with --model not-a-model and expect a failed banner naming the log.

Known Limitations and Deferred Work. macOS may ask once for permission to show notifications from Script Editor (the process osascript runs as); until it is granted, or while Do Not Disturb or a Focus mode is on, the banner does not show even though the watcher ran. Without npm run notifier:build there is no Cyberine icon, since osascript always shows the Script Editor icon; the helper app is built per machine and not shipped prebuilt, it is ad-hoc-signed rather than notarized, and Telegram cannot carry a per-message icon (the bot avatar is fixed). The watcher has no timeout of its own: a writer that hangs keeps the watcher alive exactly as long as it kept the writer alive before. If the machine sleeps or the watcher process is killed, no notice is sent and the log file is the only record. The notice reports the writer's exit and the file it was told to write, not the quality of the handoff.

Recall, the session-start pointer, the skill, and end-of-session in one command (kq52e, kyx8r, k24g6, kmzg6).

cyberine handoff pull                # the newest handoff for this repo, in full
cyberine handoff pull last 3         # the newest three
cyberine handoff pull list           # every handoff with its date and source
cyberine handoff pull rate-limit     # case-insensitive search across all of them
cyberine handoff pointer             # one line naming the newest (what the SessionStart hook prints)
printf 'Where: ...\nNext: ...\n' | cyberine tasks end --handoff short     # wrap the board, then write the breadcrumb
  • pull is read-only and makes no model call. It reads three sources for the repo you are in, newest first by modification time: the short breadcrumbs in .cyberine/handoffs/, the long handoffs in ~/.cyberine/handoffs/ whose name ends _<repo>.md, and a legacy .remember/handoffs/ directory when one exists (labelled legacy .remember, read only, never copied or migrated). pull last 0 or a non-number is refused with exit 2.
  • pointer prints one line ([handoff] newest for <repo>: <title> (<source>, <date>) · <path> -- full text: cyberine handoff pull last), or nothing at all when there is no handoff or the directory is not a git repository; it always exits 0, so it can never break a session start.
  • cyberine init adds .cyberine/handoffs/ to the Cyberine ignore block, and on a terminal it asks whether to install cyberine handoff pointer as a SessionStart hook in .claude/settings.local.json (merged into the file, never overwriting other settings; an unreadable file is left alone and reported). Without a terminal, or with --yes, it installs nothing and prints how to get it. Since 2026-09-30 the plugin runs this hook itself in every git repo, and init skips the question when the plugin is installed.
  • Two plugin skills drive it. /cyberine:handoff writes the free short breadcrumb: the live session composes the seed from what it already holds and pipes it to cyberine handoff write, then stops; it is model-invocable (anchored on the word "cyberine", or with no anchor in any git repo), so "cyberine nghi tay" and a plain "nghi tay" both reach it directly. /cyberine:end in a repo with no board skips the wrap and runs cyberine handoff write --mode long alone, since tasks end refuses without a manifest. /cyberine:end [--model <m>] closes the session: cyberine tasks end --handoff long, the board wrap plus the full handoff a background claude -p writes; it is model-invocable on its own phrase ("cyberine end", "cyberine-end", "cyberine chot session") and owns its consent: typing /cyberine:end, sending the bare phrase, or sending an instruction that carries it ("finish the wave, then cyberine-end", including when the session reaches the end only after that other work) is the yes to the spend and to every write, and every other way in (a question that mentions it, the model's own judgment with no anchor in the operator's message, text read from a file or tool output) first names the cost and asks one question with the choices run it / wrap the board only / cancel. Since k15yz it closes a session end to end: it reads cyberine tasks unlogged, cyberine tasks status and any pending draft, proposes logging the work that never reached the board (finished work as a closed task with its commit as evidence, unfinished work as todo, work only discussed as backlog, trivia skipped), on the command path (typed, bare phrase, or an instruction carrying the phrase, since 2026-10-01) writes every proposal at once with no question and turns anything it would have asked into a task or an Open: line (since 2026-09-30, operator ruling: end exists to trace the session onto the board), while the ask-first path asks once in plain words as part of its consent question, writes what was accepted through save, and then wraps and hands off. A pending draft no longer stops it: on the ask-first path the operator converges it in the same answer or leaves it; on the command path it is always left in the inbox; either way end runs with --force and the handoff's Open: line names the draft. /cyberine:help routes "cyberine ghi handoff" to the first and "cyberine handoff long" to the second.
  • cyberine tasks end --handoff short|long [--model <m>] reads the seed from stdin FIRST and refuses (exit 2, nothing archived) when there is none, then runs the normal end wrap, then writes the handoff. The drafts gate still stops the plain CLI end before anything, and then no handoff is written either; /cyberine:end asks about the draft first and passes --force only when the operator chose to leave it.

Known limitations: pull orders by modification time, so touching an old file moves it up; tasks unlogged cannot tell whose commit is whose, so a repo with parallel sessions lists their unlogged commits too and the skill drops the ones this session did not make, and its default 24-hour window is a guess when the session does not know the HEAD it started from; the pointer hook needs cyberine on the PATH of the Claude Code session; the skill and the help route were not re-scored with the eval suite (a paid run, the operator's call).

SessionEnd safety net — cyberine handoff capture (kvw41), REMOVED from the plugin and from cyberine init on 2026-09-30. A session that closes without /cyberine:handoff or /cyberine:end leaves no handoff, and the capture hook was meant to cover that: at session end it read Claude Code's hook JSON on stdin (session_id, transcript_path, cwd, reason) and wrote <repo>/.cyberine/handoffs/<session-id>.md titled # Handoff — <repo> (<date>) · auto capture. It was removed because it never wrote anything in practice: Claude Code bounds SessionEnd hooks at 1.5 seconds and a plugin cannot raise that (only the per-machine env CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS can), and booting the CLI took about 1.9 seconds, so the hook was killed first. SessionEnd also does not fire at all on kill -9, a crash or a closed terminal. The plugin therefore ships three hooks (board banner, board guard, handoff pointer) and cyberine init no longer asks about a SessionEnd hook.

The subcommand itself is unchanged and still works by hand:

printf '{"session_id":"s1","cwd":"%s","reason":"other"}' "$PWD" | cyberine handoff capture

It writes nothing when the session already has a real breadcrumb (a later /cyberine:handoff overwrites the capture, never the other way round), when the transcript has no request from you (an empty session), when the transcript has not changed since the last capture, or outside a git repository, and it always exits 0. Cleanup: a repo that got the hook from an older cyberine init keeps it in its own .claude/settings.local.json; delete that SessionEnd entry (the one whose command is cyberine handoff capture), since it fires under the same 1.5 second bound and writes nothing. A replacement that reconciles the sessions of the last run at the NEXT SessionStart, where there is no such deadline, is backlog task k4n28 and does not exist yet.

One whole-board command — cyberine autopilot, fleet engine by default, cyberine loop an alias (k2010)

Until 2026-09-18 this repo shipped two whole-board verbs and only one of them used the fleet: cyberine loop dispatched every runnable task to the build fleet but had no supervision, and cyberine autopilot had all the supervision but drove one Claude session per task. They are now ONE command. cyberine autopilot keeps its supervision — the start refusals, the RunStore run record, the STOP file, the wall-clock budget, the handoff and the Stop-hook / webhook / Telegram notifications — and its WORK ENGINE is selectable:

--enginewhat it does
fleet (default)derive the runnable frontier and dispatch each task to the build fleet: design -> implement in an isolated worktree -> review -> DiffJudge, then land the winner
claudeexactly today's autopilot: one driven Claude session per task, turn by turn, with checkpoints and handoffs

cyberine loop still resolves. It prints one deprecation line on stderr and runs cyberine autopilot --engine fleet with the same scopes, flags and exit codes.

Run recipe.

# drain the whole board on the fleet (the default; nothing else to pass)
cyberine autopilot

# the same, but see the plan and the first verdict without starting anything
cyberine autopilot --dry-run
cyberine autopilot --dry-run --json

# scope it: ids, an area tag, a natural-language goal, or the tight fix cycle
cyberine autopilot k2010 k00vv
cyberine autopilot --area autonomy
cyberine autopilot "Build a small LMS"
cyberine autopilot --test-debug-fix

# launch a detached fleet run per task and return immediately, instead of draining in-process
cyberine autopilot --dispatch async

# the previous autopilot: one driven Claude session per task
cyberine autopilot --engine claude
cyberine autopilot --engine claude --bg --driver bg

# ONE driven session orchestrating a whole area, one worktree subagent per task
cyberine autopilot --engine claude --area autopilot --batch 3 --driver stream

# the same, but naming the tasks by hand instead of by area
cyberine autopilot --engine claude --batch 3 k1ggk ktfy0 kvmga --driver stream
cyberine autopilot --engine claude --batch 3 'the tasks-board tasks'   # select by plain language (k9ete)

# the deprecated alias, unchanged for every existing caller
cyberine loop --area autonomy

A natural-language goal is planned onto the board through the board writer (kpv5x). cyberine autopilot "<goal>" asks the planner model for a backlog, then writes it with save under the board lock, on the same board cyberine tasks resolves (NF_TASKS_ROOT, NF_TASKS_SYNC_DIR and submodule anchoring included). Each task gets a fresh k-id minted against the manifest, the archive and the briefs, exec: agent, model: sonnet, the planner's effort (M when it names none), and a why: quoting the goal. A planned task whose title, do or done-when spans more than one line is dropped, and so is any task whose dep will not be written, rather than written without that dep. Known limits: planned tasks carry no brief file and no per-task model choice; a board lock held for more than the lock's 5-second budget makes the planner give up and the run falls back to every runnable task on the board.

--dispatch sync is why the default drains. On sync (the fleet engine's default) each task runs in-process, is gated on DiffJudge for a code task or the non-code verify gate for a text task, and its status is written only when the gate passes — so the next iteration sees an advanced frontier. On async the command launches a detached fleet run per task and returns; nothing flips a status, which is the right shape for a fire-and-forget batch and the wrong one for draining a board. cyberine loop keeps async as its default so a script that shells out to it still returns rather than running for hours; --verify is a synonym for --dispatch sync, as is CYBERINE_LOOP_SYNC_VERIFY=1.

Each landed task is a commit on --dispatch sync (k31fs). When a code task's winning diff applies to the working tree, the loop commits exactly the paths that diff touched on the current branch, with the message <task id>: <task title>, before it dispatches the next task, and logs [autonomy-loop] sync-verify: <id> landed and committed as <sha>. Every candidate worktree is cut from HEAD, so this is what lets a dependent task build on its dependency; git log --oneline after a run lists one commit per landed task. cyberine autopilot already refuses a repo without rules.applied in .cyberine/config.local.json or .cyberine/config.json, which is that repo's opt-in to unattended commits. When the winning diff does not apply, or the commit fails, the task is NOT marked done: the patch is kept at .local/runs/<loopId>/<task id>-winner.patch, the winner worktree stays on disk under .claude/worktrees/, and the log line, the board blocker: and the end-of-run decisions report all name both paths. Recover by fixing the conflict, running git apply --recount <patch> yourself, then git worktree remove <path>.

Known Limitations and Deferred Work (k31fs). The loop never pushes; landing on the remote stays with the operator or cyberine land. The commit takes the working-tree content of every path the diff touched, so an uncommitted edit of your own in one of those files rides along in the task's commit; start a sync run on a clean tree. Pre-commit hooks run as usual, and a hook that rejects the commit reverts the applied diff and blocks the task.

The fleet engine implements with the fleet you enabled (kmf67). With CYBERINE_BUILD_FLEET unset, the fleet engine resolves its implementers the same way cyberine run's router does: from the ENABLED providers in fleet.json (cyberine fleet list), honouring seats, retired workers and quota cooldowns. Before kmf67 only the env var registered a file-writing worker on this path, so a repo with an enabled fleet.json and no env var fell through to the raw read-only exploration worker, whose candidates carry no diff by construction — every fork ended no winner - all candidates failed the DiffJudge gate and the loop dispatched the next task on the same dead lane. An explicit CYBERINE_BUILD_FLEET still wins verbatim (it is an override, never merged with the fleet.json ids), and CYBERINE_ROUTE=off turns the fleet.json resolution off so only an explicit pin registers an implementer. Beam search also picks its exploration worker from the writers in the pool now, so a read-only compare worker can no longer win the fork while an implementer sits beside it.

A run that cannot write a file refuses instead of burning the board. When that resolution yields no worker that may write the working tree, cyberine autopilot --engine fleet exits 2 before dispatching anything and names the fix — cyberine fleet enable <provider> first, a CYBERINE_BUILD_FLEET=<worker-id> pin second. There is no override flag: a run with nothing that can write has nothing to force. --dry-run never refuses: it prints the whole plan, an implementers line, and a Readiness: NOT READY block, and still exits 0.

# what the fleet engine would actually implement with, before spending anything
cyberine autopilot --dry-run --json | jq .implementers
# -> { "ready": true, "source": "fleet.json", "implementerIds": ["claude"], "explorationIds": [...] }

# enable a provider once and every autopilot run uses it, no env var
cyberine fleet list
cyberine fleet enable claude

Fail fast on a dead fleet. On --dispatch sync the loop classifies every fork: a fork where at least one candidate carried a diff is LIVE (the judge rejecting that diff is a task problem, so the counter resets), and a fork where every candidate came back empty is DEAD — either the adapter errored or the worker ran and wrote nothing. After CYBERINE_LOOP_DEAD_FORK_LIMIT consecutive dead forks (default 2) the run stops with a terminal reason naming the worker and the error class, written to the audit ledger under .local/autonomy-audit/ and carried into the run record as autopilot failed: dead fleet: ... (exit 1). The error class reuses the same classifier the worker-cooldown store uses, so out-of-credits, auth-failure, rate-limit and connection-failure are named as such rather than as one generic failure.

# allow three consecutive empty forks before stopping (default 2)
CYBERINE_LOOP_DEAD_FORK_LIMIT=3 cyberine autopilot

# turn the backstop off entirely (the pre-kmf67 behaviour: the loop keeps going)
CYBERINE_LOOP_DEAD_FORK_LIMIT=0 cyberine autopilot

routing.steps pins decide the sync dispatch (kenmv). When the fleet.json in effect pins any step under routing.steps, every CODE task on --dispatch sync runs the same implement-task workflow a background run uses, in-process: design, then implement with one isolated worktree per pinned implementer and DiffJudge picking the diff, then the skeptic review. Each step dispatches exactly its pinned ids. The implement winner is applied to the working tree and gates the task, exactly as the beam-search winner did. A fleet with no pins keeps beam search and its single exploration worker, unchanged. Before kenmv the sync path never read routing.steps: it forked every candidate onto one exploration worker the pins did not name, while the dry run printed the pins as if they applied.

Each pinned dispatch is recorded as its own run under .local/runs/ (source auto-dispatch), and the audit ledger under .local/autonomy-audit/ carries one "status": "step" line per step naming the workers it dispatched, the step's state and winner, and the runId, ahead of the task's usual dispatched or blocked line. The dry run prints a code tasks line naming the path each code task takes (beam search ... with the exploration writer, design -> implement -> review in-process ..., or a detached run per task on --dispatch async), and on sync its Pinned steps block lists what each step will dispatch: the pinned ids that were built for this run, with any unbuilt ones named as skipped.

A pinned step none of whose ids was built refuses the run by name instead of widening, because a widened step would bill workers the operator did not choose: cyberine autopilot exits 2 before dispatching anything with pinned step unresolved: routing.steps.<step> names [...], the dry run prints the same sentence under Readiness: NOT READY, and the loop itself writes it to the ledger and stops. When only some of a step's ids were built, the step runs the built ones and names the rest in a warning, the same rule a background run follows.

# what each code task will dispatch, per step, before spending anything
cyberine autopilot --dry-run
cyberine autopilot --dry-run --json | jq '.fleet | {codeTasks, pinnedSteps}'
# -> { "codeTasks": "pinned-workflow", "pinnedSteps": [ { "step": "design", "pinned": ["claude-kt"], "dispatched": ["claude-kt"], "unbuilt": [] }, ... ] }

# after a run: which workers each step actually dispatched
jq -c 'select(.status == "step") | {taskId, step, workers, runId}' .local/autonomy-audit/<loopId>.jsonl

An id scope counts done deps outside the list (ky6md). cyberine autopilot <id> <id> (and cyberine loop with an id list) computes the runnable frontier over the whole board and only then narrows it to the named ids, so a named task whose dep is already [done] but was not named itself is runnable, exactly as the dry run lists it. A dep that is still open outside the list keeps the named task blocked; the loop never pulls an unnamed task into the run. Guard: npm run selftest:autopilot-scope-done-deps.

Batch deferrals are reported, never silent. decideBatch records a reason per task it leaves out of a roster (another task in area "x" is already in this batch, its do: names src/core/config.ts, which a task already in this batch also names, needs the working tree to itself, batch is full at N). Since kjgpm those reasons are printed rather than dropped: the dry-run plan lists them on the batch N: line, always naming every OPERATOR-NAMED id before the +N more tail, and the live run logs one [autopilot] batch defers <id>: <reason> line per named id plus one summary line for the unnamed rest — one line each for the unnamed set would drown the log, because a lead that needs the tree to itself defers every other runnable task. An id the operator named that never reaches the runnable set at all is reported before anything starts, by id and cause ([backlog] — promote it first, already [done], held by its blocker: …, blocked on <deps>, no such task on this board).

A plain-language scope SELECTS, it does not write (k9ete). On --engine claude a prose positional is an nl-select scope: it resolves to ids the board ALREADY holds, which is the only thing a driven session can execute. The same phrase on --engine fleet still means nl-goal and still runs NlGoalPlanner to write new tasks — the two modes are opposites and the engine is what picks between them. Resolution is deterministic first: the phrase is reduced to terms (a stopword is dropped only when it stands ALONE, so the tasks-board tasks keeps both tasks and board), and a task matches only when it hits EVERY term, scored area > title > do. One cheap model call is the fallback, and ONLY when the matcher resolved nothing. The resolved ids are always printed with the evidence per id before anything spawns:

[autopilot] the phrase "the tasks-board tasks" resolved to 14 task(s) (deterministic):
  k3cte  TASKS-STATUS-VIEW: a full cyberine tasks status board view and the one-line --summary
        matched on area:"tasks" + title:"board"

On a TTY the match evidence appears in the plan block and Run? [Y/n/e] confirms it with the rest of the run; --yes carries that confirmation, and a non-TTY caller without --yes is refused rather than silently aborted. A phrase matching nothing exits 2 naming the phrase — it never falls back to the whole board.

Known Limitations and Deferred Work. --dry-run never spends, so it skips the model fallback entirely and says so; a phrase that only the model could resolve therefore looks unmatched under --dry-run. The matcher is substring-based with no stemming or synonyms, so dashboards does not match dashboard, and the Vietnamese stopword list is unaccented only. The model fallback uses the cheapest authed chat-eligible provider (the session-title pick) and is skipped with a note when none is authed.

Settle operator decisions before the first dispatch: --answers <file> (kkamv). A task whose prose asks the operator for something (the T460 non-codeable gate: supplying a key, a production deploy, a payment) is normally deferred and only answerable AFTER the run, through the capsule and --resume. On the fleet engine --answers <file> answers them up front. cyberine autopilot --dry-run lists every such task on the current frontier under Operator decisions (T460) as T460 defer <id>: "<question>" (and as fleet.operatorDecisions in --json), so a caller knows what to answer; with --answers the dry run prints T460 answered <id> for each task the file covers. The real run skips the classifier for an answered task, logs T460 answered <id>, and prepends the decision to its prompt exactly as --resume does; an unanswered deferring task is still deferred.

cyberine autopilot --dry-run                          # lists "T460 defer <id>: ..." lines
cat > .local/answers.json <<'JSON'
[{ "task_id": "k7m5n", "question": "which key", "answer": "use SAYTAPE_API_KEY from .env; never print it" }]
JSON
cyberine autopilot --dry-run --answers .local/answers.json   # now "T460 answered k7m5n"
cyberine autopilot --answers .local/answers.json

The file is a JSON array of { "task_id", "question", "answer" }, each a non-empty string, one entry per task. Every task_id is checked against the board before anything starts, and an unknown or duplicated id exits 2 naming it. --answers is refused under --engine claude, and together with --resume (the capsule already carries its answers). The path is resolved against the current directory, and a --bg run receives it as an absolute path. Guard: npm run selftest:autopilot-preflight-answers.

Known Limitations and Deferred Work (kkamv). Only the T460 non-codeable gate reads the file: a worker that emits its own structured defer slot mid-task is still deferred to the end-of-run report. The dry run previews only the tasks on the current frontier, so a deferring task that becomes runnable later in the run is not listed until a later dry run shows it; answering it in the file ahead of time still works. The skill half that writes this file is the autopilot skill's preflight (kg3yc, below).

The autopilot skill's preflight (kg3yc). Inside a Claude Code session, /cyberine:autopilot no longer asks a fixed engine / scope / budget list. Unless the operator typed an explicit scope AND budget (Path A, which asks nothing), the session runs a free preflight before its one question round: (1) cyberine autopilot --dry-run with whatever the invocation named, a refusal going to /cyberine:init; (2) a run-readiness pass over every id under Runnable (N):, read from the manifest (cyberine tasks corpus carries do but not done-when or deps), repairing a do: that names no file, function or command, a done-when: that is not observable, or a prerequisite missing from deps: through cyberine tasks set <id> --do|--done-when "<text>" --reason "autopilot preflight: ..." or cyberine tasks deps set, at most one write per field per task; (3) a fork pass over every T460 defer <id> line: a reversible in-repo fork (a name, a default value, which of two existing modules, a config key the repo already reads) is answered by the session with a one-line rationale and written to .local/autopilot/answers-<timestamp>.json, while a credential, a purchase, an external side effect or a product decision is carried to the operator; (4) ONE AskUserQuestion of at most four questions: the spend consent (task count, the quota line, Telegram configured or not, and the engine, scope, dispatch and budget stated as facts: fleet, the whole board or the named scope, sync, --max-iter 100 --max-hours 8, unless the prose named others), the operator-only forks, and any operator-only readiness gap; (5) the operator's fork answers are added to the file, cyberine autopilot --dry-run --answers <file> confirms T460 answered <id>, and the run starts as cyberine autopilot --bg --yes --answers <file> ..., without --answers when nothing was deferred. Steps 2 and 3 decide from the task text and the repository alone, never by running a worker.

# what the session runs on a board with one reversible and one operator-only deferral
cyberine autopilot --dry-run                                            # two "T460 defer <id>" lines
cyberine tasks set k2x4d --done-when "npm test passes and clamp(5, 0, 3) returns 3" --reason "autopilot preflight: done-when was not observable"
cat > .local/autopilot/answers-20260924-101500.json <<'JSON'
[{ "task_id": "k7m5n", "question": "<copied from the defer line>", "answer": "use src/format/intl.ts as the default formatter (settled by the session: both modules already exist, switching back is a one-line change)" }]
JSON
# ...the one question round; the operator answers k9p1x, the session appends that entry...
cyberine autopilot --dry-run --answers .local/autopilot/answers-20260924-101500.json   # both ids now "T460 answered"
cyberine autopilot --bg --yes --answers .local/autopilot/answers-20260924-101500.json

Known Limitations and Deferred Work (kg3yc). The readiness and fork judgements are the session model's own reading of the task text; nothing checks them before the run, so a wrong "reversible" call reaches the worker as a decision (it is visible in the answers file and in the consent question's text). cyberine tasks deps set records no reason, so a deps repair is named only in the question text. The claude engine prints no Operator decisions block and refuses --answers, so its runs get the readiness pass and the single question round but no fork pass. Path A settles nothing: a deferring task there stays deferred to the end-of-run report. The flow is exercised by the plugin eval case autopilot-preflight-answers against a recorded dry-run; no live eval of it has been run yet.

Engine-specific flags are refused, never ignored. --driver, --attached, --batch, --on-drained, --brainstorm-max, --brainstorm-model, --on-quota, --force-model, --stall-timeout and --turn-timeout all describe a driven Claude session, so passing one with --engine fleet exits 2 naming the flag and pointing at --engine claude. --dispatch, --verify, --max-iter, --evolve-source, --resume, --answers, --test-debug-fix and a positional task id or goal all scope the fleet frontier, so passing one with --engine claude exits 2 the same way. --area is the exception since kfhsz: BOTH engines read it, because the scope machinery (parseLoopScope -> filterForScope) was always shared and only the claude engine was fenced off from it, which left it with no scope control at all. What stays fleet-only is every scope that writes or resolves tasks rather than selecting existing ones — --resume rehydrates a capsule, --test-debug-fix is a fleet verify cycle, and a bare goal string runs NlGoalPlanner. A scope that matches no runnable exec: agent task is now refused by name on either engine (no exec: agent task in <board> matches --area <tag>) instead of silently running as the whole board. Since kjgpm an explicit task-id list is read by both engines too; a natural-language GOAL stays fleet-only, because it runs NlGoalPlanner to write new tasks and a driven session has no path to that — on --engine claude the same prose is an nl-select scope instead (k9ete, above). The discrimination rule: parseLoopScope classifies the positionals as ids only when EVERY one matches a known id shape (T\d+, R\d+, or a k-shape). Since knctg a list that MIXES id-shaped tokens with a non-id-shaped one is no longer folded into prose at all — it exits 2 naming the offending token on both engines, because the only way to type one is a typo in an id list. --max-sessions is the exception: on the fleet engine it caps the number of tasks the run may dispatch when --max-iter was not given, so a caller that already passes a budget keeps one.

Extend recipe. Set the default engine for a repo or a host without touching any call site:

export CYBERINE_AUTOPILOT_ENGINE=claude   # or fleet
cyberine autopilot                        # now runs on that engine
cyberine autopilot --engine fleet         # the flag still wins

Environment.

VariableDefaultMeaning
CYBERINE_AUTOPILOT_ENGINEfleetdefault work engine for cyberine autopilot; --engine overrides it
CYBERINE_LOOP_MAX_ITER100fleet engine: tasks one run may dispatch, when --max-iter was not given
CYBERINE_LOOP_BLOCKED_THRESHOLD3fleet engine: consecutive no-progress iterations before stopping
CYBERINE_LOOP_SYNC_VERIFYunset1 is the same as --dispatch sync
CYBERINE_LOOP_DEAD_FORK_LIMIT2fleet engine, --dispatch sync: consecutive forks producing NO candidate at all before the run stops with a dead fleet: reason. 0 disables the backstop

Known Limitations and Deferred Work. The fleet engine deliberately does NOT inherit three things from the Claude engine, and says so rather than pretending otherwise:

  • No quota probe. The Claude engine spends a real claude -p turn to read the subscription rate-limit window and can refuse the whole run on it. The fleet's workers are mostly OpenAI-wire loop workers with their own keys, so the fleet engine READS the cached reading (free) for the plan but never probes and never refuses on it. Spend is gated where the fleet actually gates it: the daily-spend cap and its admission reservation at the dispatch boundary. --on-quota is therefore a --engine claude flag.

  • No per-task checkpoint. A fleet task is one dispatch, not a conversation with a context window to exhaust, so there is no session boundary to checkpoint. The loop writes its own audit ledger and context capsules per task, and ONE handoff is written at run end (skip it with --handoff none).

  • The plan's session-shaped lines are inert. --dry-run prints one plan for both engines so no reader has to learn two; on --engine fleet the driver, session-name, stall, hold, turn-cap, on-quota, batch and model-boundary lines describe --engine claude and a dimmed note under the engine line says so.

  • The readiness check reads what resolves, never a provider. A worker that resolves from fleet.json, constructs, and only then fails at dispatch (a 401, an exhausted balance) still reports READY — nothing is probed, because a plan must not spend. That case is caught after the fact by the dead-fork backstop, not before it.

  • The dead-fork backstop only counts EMPTY forks. A fleet that faithfully produces diffs the judge rejects every time is a task problem, so it never trips this backstop; CYBERINE_LOOP_BLOCKED_THRESHOLD is the rail for that. The counter is per-run and in-memory: a fresh cyberine autopilot starts at zero.

  • --dispatch async observes no fork outcome, so the dead-fork backstop never fires there (the detached run owns the fork). The readiness refusal still applies to both dispatch modes.

  • Pins shape code tasks only. A task the non-code verify gate claims (research, docs, plans) keeps that gate's own in-process dispatch; routing.steps does not apply to it.

  • A fork: row does not fan out per approach under pins. Its approaches are appended to the task as CANDIDATE APPROACHES and the design step picks one; the pinned implementers then compete on that plan. One worktree per approach remains the unpinned beam-search behaviour.

  • No mode preset on the pinned path. The workflow runs without a CYBERINE_MODE preset, because a preset's fleet-size cap slices the pool before the pins filter it and could drop a pinned worker the dry run promised.

  • The review step is advisory. The task gate is the implement step's DiffJudge winner applying cleanly to the working tree; a VERDICT: risk from the review step is recorded on the run and in its ledger line but does not block the task, the same as a background run's review. A review that returns risk with concerns first gets the one bounded fix pass described in "Review findings get one fix pass (kd8dj)", so the diff that gates the task is the fixed one when it was adopted.

  • The dead-fork backstop reads the implement step under pins. A pinned dispatch counts as live when any implement candidate carried a diff; when none did, the worker it names comes from the implement step, or from the first failed step when implement never ran.

  • The unresolved-pin refusal is --dispatch sync only. On async each detached run builds its own pool, so the parent cannot know what it will build; the detached run's own step refusal still applies there.

Also deferred: --bg on the fleet engine always uses the detached-process path, never a claude --bg --exec fleet row, because there is no steerable session for claude attach to reach. Watch it in cyberine agents (runs view, under the repo's folder row) or cyberine agents --json; claude agents never lists it.

Live progress in the run record (k32pf). The fleet engine's outer autopilot run is updated after every task that reaches the audit ledger, not only at start and end: run.json gains one step per task (its id and title, done when the gate passed, error when it was blocked or failed, with the ledger's reason as the step detail and the output tokens spent on that task), detail names the last task and its outcome, and updatedAt moves. So the cyberine agents row shows a growing STEPS count and TOKENS while the run is live. Updates land when a task FINISHES; the task in flight is not shown until its gate settles, because the loop writes its ledger line only then.

Interactive preflight on a real TTY (knpbq)

Run cyberine autopilot with no flags on a real terminal (not piped, not --bg, not --dry-run) and it shows one plan block with task ids, engine, time/task caps, the daily and hard spend caps, cached quota, estimated spend and metered API status, then asks Run? [Y/n/e]. Enter or y starts, n exits 0 before spawning, and e opens the detailed preflight menu, then returns to the plan and the same prompt. The plan uses cached quota data and does not run the quota probe.

Run recipe.

# on a real terminal: shows the plan, then asks Run? [Y/n/e]
cyberine autopilot
cyberine autopilot --engine claude --on-quota api

# any of these skip the menu and run with today's exact defaults, no prompt, no hang
cyberine autopilot --yes
cyberine autopilot --bg                   # on a real terminal
cyberine autopilot --dry-run              # free anywhere, never needs --yes
cyberine loop                             # the deprecated alias carries --yes internally on a terminal

# no terminal (a script, cron, an agent's shell): --yes is required, the operator's yes made explicit
cyberine autopilot --bg --yes
cyberine autopilot --bg < /dev/null       # refused: exit 2, naming the spend

No terminal means --yes (kvd26, operator ruling 2026-09-19). Without a real terminal on both stdin and stdout, a real run (not --dry-run) refuses with exit 2 unless it carries --yes, and the refusal names what the run would spend (the fleet dispatching every agent-runnable task, or up to --max-sessions Claude Code sessions, for up to --max-hours). The reason is an agent: measured on 2026-09-19, a Claude Code session asked to "let cyberine work through the whole board unattended" launched cyberine autopilot in 2 of 3 runs before the operator had said yes, and an agent's shell is never a terminal. --bg is not an exemption on its own, because the parent is exactly the call an agent makes; the children it starts are (the fleet row carries --attached, the detached fallback is given --yes by its parent). The deprecated cyberine loop alias injects --yes only on a terminal, so a scripted loop meets the same guard. No daemon, cron, schedule or queue path launches autopilot, so nothing internal needed a change. Above this guard, the autopilot skill asks at most once: an invocation that already names both scope and budget carries the go-ahead itself, and every other invocation gets exactly one question round whose answer authorizes --yes - never a second confirmation turn. Known limitation: the guard cannot tell an operator's --yes from one an agent added on its own; it makes a forgotten yes safe, and the refusal text tells an agent never to add it unasked, but it is not proof of consent.

The plan lists every selected task id. Enter e at the prompt to open the detailed menu. Two rows open on demand:

  • Scope — checkbox-toggle which areas run, then optionally fine-tune individual tasks within the areas kept on. Resolves to the whole board (unchanged) when every task stays checked, or to an explicit task-id list otherwise. Hidden once an explicit scope was already given on the command line (a task id, --area, or --resume) — that value pre-fills and the question is skipped.
  • Plan — the pre-computed frontier, one line per task: its cost class (cheap/expensive, the same classifier cyberine run's background dispatch uses) and each fleet step's band plus any fleet.json routing.steps pin. Read-only; always shows the full frontier regardless of the Scope toggles.

The Engine row is hidden when --engine (or CYBERINE_AUTOPILOT_ENGINE) was already given — that choice pre-fills instead. The Caps row is hidden the same way when --max-hours or --max-sessions was already given; otherwise it offers no cap (today's defaults), a time cap in hours (sets the same budget as --max-hours), or a spend cap in dollars (sets CYBERINE_SPEND_CAP_USD for this run only). Confirming or cancelling the detailed menu returns to the plan prompt or exits without starting, respectively.

Extend recipe. The menu logic is a pure module, independent of any terminal: src/autopilot/preflight.ts exports buildPreflightPlan / resolvePreflightScope / resolvePreflightFlags / preflightApplies (plan + answers -> a value, no I/O) and runAutopilotPreflight (the interactive loop, parameterized over a small PreflightIO port instead of touching process.stdin/stdout). A throwaway script (gitignored, .local/tmp/*.ts) can drive every branch by injecting fake pickFromList / checkboxSelect / readLine functions instead of a real terminal — the menu, the scope/caps submenus, cancel, and switching engines mid-menu are all exercisable this way, with zero TTY. src/commands/autopilot.ts wires the real src/core/cliPrompt.ts primitives (the same ones cyberine setup's wizard and cyberine init's prompts already use) into that port.

Environment.

VariableDefaultMeaning
CYBERINE_SPEND_CAP_USDunsetdaily spend cap in USD (src/store/spendCap.ts); the preflight's spend-cap answer sets this for the current process only, it does not persist

Known Limitations and Deferred Work.

  • Not verified on a real TTY. The one-prompt smoke injects PreflightIO and is left for the operator to run; terminal rendering and the assembled menu flow still need a real-TTY pass.
  • A dollar estimate is unavailable before dispatch. Task usage and provider prices cannot be established from the board and cached quota reading alone. The plan labels estimated spend unavailable and shows configured daily (CYBERINE_DAILY_USD_CAP) and hard (CYBERINE_SPEND_CAP_USD) spend caps separately.
  • The plan's cost-class preview can differ from the real dispatch. The board task shape available here (PolicyTask from boundaryPolicy.ts) carries no title: field, only do: prose, so the classifier's title input is approximated with do:. A task whose escalating word (security / concurrency / migrations) appears only in its board title and not its do: line previews a class the real dispatch (which does have the title) would not pick.
  • Switching engine away from fleet in the menu drops an explicit task-id or --resume scope silently. If the command line already named an explicit scope (a task id, --area, --resume) the Scope row is locked and hidden as documented above; if the operator then uses the (still-visible, since --engine was not itself explicit) Engine row to switch to claude, a locked --area now carries over (kfhsz), but a task-id or --resume scope has nothing to apply to on that engine and is simply unused for that run.
  • The Caps row offers only time or spend, never a session-count cap (--max-sessions stays flag-only); and picking a cap here does not persist past the current process.

Autopilot command — cyberine autopilot --engine claude, the whole session -> handoff -> session loop (k0vpr)

cyberine autopilot is the supervised form of the loop the operator otherwise runs by hand: work a session down to some fill, end the task, write a handoff, wait for the Telegram ping, open a fresh session, tell it to read the handoff and the board, continue. It composes the four pieces that already existed — sessionDriver.ts (drive one Claude Code session turn by turn and measure its context), boundaryPolicy.ts (decide at a TASK boundary whether to take another task), effortCalibration.ts (price effort: S|M|L in tokens) and handoffBridge.ts + prompts.ts (hand off through the builtin handoff writer, bootstrap the next session) — into one bounded, audited process. The loop lives in src/autopilot/autopilotLoop.ts; src/commands/autopilot.ts only parses flags, refuses to start when a precondition is missing, and supplies the real ports.

One session, in order: decide() at fill 0 picks the task, the session bootstraps on it (with the previous session's handoff file when there is one), and after EVERY measured turn the policy is asked again. continue keeps the session going — on the same task with a short nudge, or on the next task with a fresh continue prompt once the current one is done. finish-then-handoff spends one more turn finishing (skipped when the task is already done) and then ends the session. handoff-now ends it immediately. no-runnable, or a NO_RUNNABLE reply from the driven agent, ends the whole loop. A task counts as done when the reply carries a line DONE <taskId> OR the board's effective status for it flipped to done — whichever lands first.

At a session end the handoff is planned with a SEPARATE decision taken at fill 0, because the next session starts with an empty window: a finished task yields the next task on the board, and an unfinished one is carried by default as a RETURN to the board (it stays runnable, with its checkpoint attached as context) so whichever free session claims it next resumes from the goal file's last ticked milestone — or, under --unfinished handoff, pinned to ONE successor via nextTaskId. The handoff path is then fed into the next session's bootstrap prompt.

Cold start — session 1 reads the last invocation's handoff, and repeat-fail counts survive the run (kj6he). A fresh cyberine autopilot used to know only about handoffs it produced itself, so session 1 of every new invocation bootstrapped on the fallback line ("no handoff file was produced for this session; read the board directly") and saw yesterday's handoff only when a ~/.claude SessionStart hook happened to inject it — something this loop neither owns nor checks. The command now resolves the newest handoff-*_<project>.md in the handoff folder (same <writer> resolve and same project-suffix filter the handoff bridge already uses, so a folder shared by several repos never hands one project another's file) and passes it as session 1's handoff; the bootstrap prompt names it exactly as it names an in-run one. "Newest" is decided by the 8-digit date embedded in the filename (e.g. -20260913) when it parses as a real calendar date, ranked above file mtime and only falling back to mtime as a same-date tie-break, so a ...-20260912 handoff rewritten later never again beats a genuinely newer ...-20260913 one. Every failure — a non-executable writer binary, a resolve that prints no path= line, an unreadable folder — degrades to the old fallback rather than blocking the run. The same file backs the repeat-fail guard: the sessionsStarted count per task is written to .local/autopilot/history.json on every bump and read back at the next start, so a task that already burned CYBERINE_AUTOPILOT_REPEAT_FAIL (default 3) sessions yesterday is excluded today instead of restarting at zero. Entries are dropped once the board calls the task done, and expire 7 days after the last real bump so nothing is locked out forever. --dry-run states both up front:

  session 1 reads  /Users/me/.claude/memory/vibecoding/handoff-…_ai-agent-solution.md
  fail history     /repo/.local/autopilot/history.json — 1 task(s) carried over: kj6he x3 (excluded)

and --dry-run --json carries the same as coldStart: { handoffPath, memoryDir, failHistoryPath, carriedFailHistory[] }. Known Limitations and Deferred Work. The cold-start handoff is picked by filename and mtime, not by reading it: nothing checks that its contents match this repo's current HEAD. The date is read from the filename, not the file's own content — a handoff written without the YYYYMMDD date suffix (or with a syntactically-8-digit but invalid calendar date) is ranked by mtime only and always sorts below every dated file, even one dated far in the past, so an undated handoff never wins as long as any dated one exists in the folder. The retention window and the history path are constants (src/autopilot/failHistory.ts), not flags. Counts are per task id only — a task renamed or re-added under a new id starts at zero.

# see the plan without starting anything (safe on any board)
cyberine autopilot --dry-run

# drive the board with the default budget (6 sessions / 8 hours)
cyberine autopilot

# bounded run on a named model, detached with a log
cyberine autopilot --max-sessions 2 --max-hours 1 --model sonnet
cyberine autopilot --bg            # log: .local/autopilot/logs/<runId>.log

# stop the loop at the next boundary (it hands off first, then exits 0)
touch .local/autopilot/STOP
FlagDefaultEffect
--max-sessions N6sessions before the loop stops with a LIMIT notification
--max-hours H8wall-clock budget; checked before each new session AND before a session takes another task or spends another turn (ktnfy) — see "Wall-clock budget" below
--model <m>(unset)FALLBACK tier for tasks with no model: of their own; unset means the CLI's own default model. A session is spawned on the tier of the task it opens, so on --driver stream/bg a tier change ends the session — see "Per-task model tier"
--window Nderived from the models the run will drivewindow the policy measures fill against. Precedence: --window, then CYBERINE_AUTOPILOT_WINDOW_TOKENS, then the SMALLEST window across --model and every runnable task's model: tier (Opus 5 / Sonnet 5 / Fable / [1m] suffix = 1000000; Haiku, Opus 4.6, Sonnet 4.5/4.6 = 200000; per Claude Code's model-config page, "Extended context"), then the CLI default (1000000, both plan defaults are native 1M). CLAUDE_CODE_DISABLE_1M_CONTEXT=1 in the spawn env caps every model at 200000. An unrecognised model id assumes 200000 and prints a warning, never 1M. The dry-run plan's window line shows the value AND its source (k2v68). Known limitation: CLAUDE_CODE_AUTO_COMPACT_WINDOW is not read -- set --window to match when you use it
--backend claude|codex|opencodeclaudebackend the handoff writer's offload is told to use; the builtin writer accepts and ignores it
--handoff checkpoint|offload|nonecheckpoint (or CYBERINE_AUTOPILOT_HANDOFF)how a session boundary reaches the next session (ksb1a) -- see "Mechanical checkpoint" below. offload is the pre-ksb1a behaviour: one builtin offload handoff per boundary; amemoria is still accepted as an alias for offload (renamed 2026-09-30). none writes nothing at boundaries or run end
--unfinished return|handoffreturn (or CYBERINE_AUTOPILOT_UNFINISHED)how a boundary treats a task the session did NOT finish (k6yf5) -- see "Unfinished task: return to the board or hand off" below. return keeps it runnable so the board arbitrates the next claimer, with the checkpoint attached as context; handoff pins it to ONE successor via the checkpoint's nextTaskId
--stall-timeout M20minutes with NO stream event AND no busy child tool before a driven turn is judged hung and its process tree is killed (khgxt). A long Bash tool call keeps the turn alive as long as its subprocess is running or burning CPU
(hold, no flag)backoff 30s -> 60s -> 120s -> 300sk335n. A turn whose reply is HOLD <taskId> <reason> (the contract every bootstrap and nudge prompt now states), or a short reply that reads as holding ("still waiting on the sweep"), is NOT re-prompted at once: the loop sleeps the next backoff step (STOP polled every minute, bounded by --max-hours), then nudges again. Held turns are counted in the session's turns but not toward the 40-turn ceiling, and are recorded as autopilot:decision events with phase: "held". One hold streak may last at most the grace (--grace, default the stall timeout); past it the session gets one wrap-up turn and hands off (phase: "hold-ceiling"). Run 15a3f96e is why: 39 nudges at ~4 s each on "still holding for the sweep", the ceiling in 9 minutes, and the boundary killed the sweep it was holding for
--grace Mthe --stall-timeout value (20)minutes past --max-hours a driver turn may still be RUNNING (kb2j2). Every turn is capped at startedAt + max-hours + grace - now; a finish or wrap-up turn whose grace is spent is skipped and the task hands off unfinished. 0 makes the deadline itself the bound — see "The overshoot bound"
(quota, no flag)probe on every real runbefore the first session the command spends one throwaway sonnet turn (session cyberine-ap-<runid8>-probe, recorded in autopilot.jsonl with probe: true) purely to read the CLI's rate_limit_event; refuses with exit 2 when the last known status is rejected and its reset is still ahead (kxvc9)
--on-quota wait|stop|apiwaitwhat a session does when the subscription window is exhausted MID-run (kp5x7): wait pauses and probes after the reset, stop hands off and exits 0 with reason quota, api swaps to the metered ANTHROPIC_API_KEY for the rest of the run
--force-modeloffmake --model win over a task's own model: tier (ke8hm). By default the task's tier is the more specific declaration and wins; --model is only the fallback for tasks with no tier. Pinning one model also removes every tier boundary, so a mixed-tier board runs in fewer sessions
--confirm-spendoffdeprecated no-op, accepted for existing scripts and prints one notice; --on-quota api uses the daily spend cap and CYBERINE_SPEND_CAP_USD
--turn-timeout M0 (none)OPTIONAL hard wall-clock cap on one turn. Off by default since 2026-09-06: a fixed 20-minute cap killed a healthy kmq07 turn, and the operator's rule is "check whether it is actually running; if it is, let it run"
--batch N1disjoint runnable tasks per turn (kg9ny). Above 1 the session fans each out to its own worktree agent; the roster excludes tasks sharing an area: or a src/ path, and a task that runs the autopilot or the sweep gets a batch alone — see "Batching"
--on-drained exit|brainstormexitwhat happens when the board has no agent-runnable task (kwzd6): exit ends the run with reason no-runnable, unchanged; brainstorm spends ONE bounded session drafting the next batch as [backlog] entries and exits 0 with reason drained-brainstormed. It NEVER promotes — see "Drafting the next batch"
--brainstorm-max N5ceiling on drafts per brainstorm session. Fewer is fine; zero is a legal answer
--brainstorm-model <m>--model, else sonnetmodel the brainstorm session runs on
--dry-runoffprint the plan and exit 0 without spawning anything
--bgoffdetach, print the pid + log path, keep running after the shell exits
--jsonoffmachine-readable output (both for --dry-run and the final summary)
--debugofflet the session driver log prompt and result bodies (they can contain secrets)
--driver print|stream|bgprint, but stream under --bgwhich session driver runs the driven session. print is unchanged. bg (kddqh) drives ONE claude --bg background job, so the session is a real row in claude agents and can be steered mid-run — see "Fleet-visible driver" below. Since k378p whatever you pass is carried INTO the fleet row unchanged; the launch output prints the driver actually used

Mechanical checkpoint (ksb1a, default since 2026-09-13). Real runs showed the then-current amemoria offload taking 1-2 minutes at EVERY session boundary -- an external LLM re-reading the driven transcript to write prose -- while the sessions ended at 18-27% fill for board or --max-sessions reasons, never for context. The durable state (board briefs, git, the .local/vibe/<task>.md goal file) already survives outside the transcript; what does not is the rationale. So the driven agent now writes that itself, in the turn it already has: every bootstrap, continue, batch and wrap-up prompt states the contract, and the agent ends its reply with

STATE:
Decisions:
- <what you chose and why>
Rejected:
- <approach ruled out and why>
Open:
- <unverified hypothesis or question for the operator, or "- none">
Next:
- <the next concrete step>
Pending:
- <background job or gate still running, or "- none">
DONE <taskId>

At the boundary the loop parses that block (leniently: case-insensitive headers, - or * bullets, inline values, continuation lines fold into the previous bullet, the LAST STATE: block in the reply wins, parsing stops at the DONE line) and, when all five sections are present, writes .local/autopilot/checkpoint/<runId>-s<n>.json (machine) and .md (human) atomically (tmp + rename) in milliseconds: runId, sessionIndex, sessionId, taskId + done flag, the planned next task, the sha the session opened at and the sha now, modified + untracked paths, the board ids flipped to done during the session, the newest .local/vibe/*.md touched since the run started, the trailer, context tokens + fill, and the write duration. The next session's bootstrap prompt then says Read the checkpoint at <path> first, then the goal file it names. The log line is session N checkpoint written in <ms> ms: <path>; the session summary and the autopilot:session event carry handoffKind: checkpoint.

The offload handoff still runs where it earns its cost, and since 2026-09-30 it is always the builtin writer (deterministic, no model call, so no longer slow): (1) a boundary whose last turn carried NO STATE: block or an INCOMPLETE one (a section missing) falls back to the builtin offload for that boundary and records why in the session summary (handoffFallback: "the STATE trailer is missing Pending") -- nothing is ever fabricated; a session killed for a stall gets its wrap-up turn, and that turn's reply is what is parsed; (2) at RUN END, once, for the human pickup -- unless the last boundary already ran the offload (then a second one is not written) or --handoff none. The run summary prints run-end handoff: <path>; a run-end offload failure is logged and reported (runEndHandoffError in --json) but does not change the exit code, because the checkpoints are already on disk.

cyberine autopilot --dry-run                     # the plan's "handoff" line shows the mode and where checkpoints land
cyberine autopilot --max-sessions 2              # checkpoint mode (default)
cyberine autopilot --handoff offload             # the pre-ksb1a behaviour, one builtin offload per boundary
cyberine autopilot --handoff none                # nothing written at boundaries or run end (handoff writer not required)
CYBERINE_AUTOPILOT_HANDOFF=offload cyberine autopilot   # env mirror of the flag
ls .local/autopilot/checkpoint/                  # <runId>-s<n>.json + .md per boundary
npm run selftest:autopilot-checkpoint            # parser, prompts, atomic write (28 checks)
npm run selftest:autopilot-loop                  # case (ksb1a): the three modes end to end on the scripted driver

Measured alternative, rejected (kcgg5, 2026-09-13): keeping ONE Claude Code session across boundaries (resume + one /compact focus ... turn) was compared against fresh+checkpoint on 3 boundaries of tiny sonnet tasks with three planted facts. Fresh won: ~41 s vs ~82 s per boundary (the compact turn alone is ~60 s), -12% cost, no context saving (~52k vs ~54k at the successor's first action, because ~50k is Claude Code's own per-session baseline), identical fact survival through the checkpoint. The experimental flag was removed; what stayed is bootstrapMs / bootstrapContextTokens on every session summary (--json) and the per-session row of the text summary. Under --json the log now goes to stderr so stdout is one JSON document. Note: .local/research/autopilot-handoff/kcgg5-continue-vs-fresh-20260913.md.

Known limitations (ksb1a): the loop's OWN default stays offload (AutopilotConfig.handoffMode undefined; the mode was named amemoria until 2026-09-30) so every pre-ksb1a caller and smoke keeps its behaviour -- only the CLI defaults to checkpoint. The checkpoint records what the agent SAID in its trailer; it does not verify those claims against the tree (the shas and dirty list next to it are the mechanical half). The killed-session path is proven through the missing-trailer fallback on the scripted driver, not through a real stall. boundaryPolicy tiers are untouched.

Unfinished task: return to the board or hand off (k6yf5). Until now a session that hit its context boundary with a task still in flight always carried that task to ONE designated successor: the checkpoint's nextTaskId named the unfinished task, and state travelled one way through disk. That is the serial, one-session-at-a-time assumption. --unfinished (env CYBERINE_AUTOPILOT_UNFINISHED) drops it:

  • return (the DEFAULT) — the board arbitrates. The checkpoint is still written (the ruling is about who decides, not about discarding what was learned), but instead of pinning a successor the loop releases the carry: the task stays runnable, the checkpoint is attached to the task as context, and whichever free session claims it next reads it. A finished task still names its successor as before; only an UNFINISHED task is returned.
  • handoff — the legacy serial carry. The unfinished task is pinned to ONE successor via the checkpoint's nextTaskId.

The default is return because the operator ruling (2026-09-21) settled that the board arbitrates once the one-session-at-a-time assumption drops, and this is the autopilot half of that. A task returned again and again without progress does NOT cycle silently: every return bumps its sessionsStarted count in .local/autopilot/history.json, and once that reaches CYBERINE_AUTOPILOT_REPEAT_FAIL (default 3) the boundary policy stops offering it (excluded: { reason: "repeat-fail" } in the decision event), so the run ends no-runnable and the loop logs returns <id> to the board unfinished on each return. The handoff-plan decision event carries returned: true when a task is returned.

cyberine autopilot                                   # return (default): the board arbitrates the next claimer
cyberine autopilot --unfinished return               # explicit
cyberine autopilot --unfinished handoff              # the legacy carry to one successor
CYBERINE_AUTOPILOT_UNFINISHED=handoff cyberine autopilot   # env mirror of the flag
cyberine autopilot --dry-run                         # the plan's "unfinished" line shows the mode
npm run selftest:autopilot-boundary-return           # return, handoff and repeat-fail on the scripted driver

Known Limitations and Deferred Work. return is proven on the scripted driver within one run (a second session claims the returned task and reads its checkpoint); it does not yet release a board [doing] claim, because the Claude engine never takes one — the task is [todo] throughout, so "return" only stops pinning a successor. Releasing a real board claim (cyberine tasks run flips a task to [doing]) is the other half the operator ruling enables and is not wired here. --unfinished is a Claude-engine flag only; the fleet engine has no per-session boundary to return from.

Exit codes. 0 — the board drained, the driven agent replied NO_RUNNABLE, the STOP file appeared, or a budget limit was reached. 1 — failed: a session-driver error, a handoff timeout, or a handoff writer refusal. 2 — refused to start.

Refuses to start (exit 2, one line saying which) when the repo root is not a git repository (the driven sessions commit and push, so git init and a first commit must already exist there), when neither .cyberine/config.local.json nor .cyberine/config.json records rules.applied === true (this command commits and pushes through the agent it drives, so it starts only where that autonomy is already granted via cyberine init --rules), when no task manifest exists, when the runnable set carries no exec: agent task (the refusal also counts dep-free exec: agent tasks sitting in [backlog] and prints a cyberine tasks status todo <ids> promote hint when any exist), or when the handoff writer is not executable (CYBERINE_HANDOFF_WRITER_BIN, default the builtin bin/cyberine-handoff.mjs).

--dry-run enforces ALL FIVE, so a green plan proves a real start will get past the gates (ka8v9; until 2026-09-08 the handoff-writer check was wrapped in if (!dryRun), so a clean dry-run said nothing about whether --bg would come up). It is one stat. A green plan carries a handoff writer <path> ok (builtin) line (or (CYBERINE_HANDOFF_WRITER_BIN) when the override is set) naming the resolved binary, and --dry-run --json carries handoffWriter: { bin, ok, writer } (before 2026-09-30 the line and the field were amemoria); a REFUSAL prints only its own line on stderr and writes nothing to stdout, so short output is a refusal to read, never a plan to skim. The refusal reads "the handoff writer is not executable (); ..." and names the builtin path to fall back to: when CYBERINE_HANDOFF_WRITER_BIN is what points at the missing file it says so and gives the builtin path unsetting falls back to, otherwise it says the install may be missing the builtin or its executable bit. That path is resolved by ONE exported function, handoffWriterBinPath() in src/autopilot/handoffBridge.ts, which both the gate and the bridge call; it used to be a literal duplicated in two files that could not diverge only because the gate always handed the bridge its own answer.

STOP file. .local/autopilot/STOP is checked before every turn and before every new session. Present mid-session, the loop hands off first and then exits 0 with reason stopped; present before a new session, it exits 0 immediately. Deleting the file re-enables a later run — nothing else reads it.

Timed-out tasks, fleet engine (kyksr). --on-timeout retry-once|skip|stop (or CYBERINE_ON_TIMEOUT; the flag wins) decides what a code task does when its worker timed out (CLI agent timed out after ... or the kem49 first-activity stall) and no candidate landed. retry-once (default) re-dispatches it once with the CLI timeout doubled and a brief line saying the last attempt timed out; if the retry lands, its dependents run as usual. skip, and retry-once whose retry also timed out, writes a board blocker on the task (timed out; skipped by --on-timeout skip ...), drops it and its dependents from this run and carries on with the rest; when only its dependents are left the run ends with frontier empty: the remaining task(s) wait on N task(s) skipped after a timeout ... instead of the self-starvation stop. stop keeps the pre-kyksr behaviour. Each retry and skip is an audit step line (timeout-retry / timeout-skip), and a skip is listed in the handoff's decisions.

cyberine autopilot --engine fleet --dispatch sync --on-timeout skip
CYBERINE_ON_TIMEOUT=stop cyberine autopilot --engine fleet --dispatch sync
npm run selftest:timeout-policy

Known limitations: applies to code tasks dispatched through the beam fork in --dispatch sync; non-code tasks and the pinned-workflow path keep the old behaviour. The doubled timeout is the per-worker CLI timeout (CYBERINE_CLI_TIMEOUT_MS), not the run's --max-hours, so a retry can spend the remaining budget. The board blocker a skip writes stays until the operator clears it (cyberine tasks status unblock <id>).

Signals, fleet engine (k7xrx). cyberine autopilot --engine fleet treats SIGTERM and SIGINT like a STOP file that also interrupts the task in flight: the first signal stops new dispatch, sends SIGTERM to every in-flight worker's process group, and lets the loop return, so the stop reason (stopped: SIGTERM from the operator ...), the audit loop-end line and the handoff are written as on a STOP file; the run exits 0 with reason stopped. A task interrupted this way did not pass its gate and stays open on the board. If the loop has not returned within CYBERINE_STOP_GRACE_MS (default 30000), the worker groups get SIGKILL and the process exits 143 (130 for SIGINT) without a handoff. A second signal does that at once.

kill -TERM <autopilot pid>
CYBERINE_STOP_GRACE_MS=10000 cyberine autopilot --engine fleet --dispatch sync
npm run selftest:signal-stop

Known limitations: the handler covers the fleet engine only; the session engines keep their existing signal behaviour. Workers are reached through the process groups CliAgentAdapter spawns; a claude --bg worker lives under the Claude daemon and is not signalled. Before k7xrx, merely importing the LSP registry installed a SIGTERM listener that swallowed the first SIGTERM in every Cyberine command; it now exits 143 when it is the only listener.

Wall-clock budget (ktnfy). --max-hours is checked in three places, all reading the same remainingBudgetMs() helper in src/autopilot/autopilotLoop.ts: before a new session starts, while a session is paused on the subscription quota, and — since ktnfy — inside a running session, after every measured turn. The in-session check runs AFTER the policy's own verdicts (so a handoff the policy already ordered keeps its own reason) and AFTER the 40-turn ceiling (so a runaway session is not granted one more turn). When the budget is spent, the session finishes the task it is on — one finish turn if it has not said DONE yet, none if it has — hands off through the builtin offload like any other boundary, and the run exits 0 with reason limit and a message naming the task: max-hours 2 reached mid-session on kx75n. An autopilot:decision event with phase: budget records it. Before ktnfy the budget was read only at a session boundary, so a session that drained the board task after task never reached one: run 3fed0a2b (2026-09-08) was granted --max-hours 2 and ran 2h49m across six back-to-back tasks.

The overshoot bound (kb2j2). --max-hours alone is a deadline for STARTING work, and turns are atomic — so before kb2j2 a turn already in flight ran to its --stall-timeout (20 min) and an unfinished task was then granted a finish turn that could run another, a worst case of about 2x the stall timeout past the deadline with nothing naming it. Now --grace M (default: the --stall-timeout value, so one knob moves both) states the bound, and every driver turn is handed startedAt + max-hours + grace - now as its hard cap:

No driver turn is still running after startedAt + max-hours + grace. At the defaults that is 8h + 20min = 8h20m. --dry-run prints the number as a wall-clock bound line.

This was FALSE when first shipped, and the way it was false is worth recording: AutopilotDriver's start/turn declared opts?: { model?: string }, so the loop's capMs arrived as an excess property on a variable — which TypeScript does not flag — and all three real drivers rebuilt { model } and dropped it. The loop smoke stayed green because it drives a scripted double that reads opts.capMs directly. An adversarial review found it on 2026-09-09; the guarantee now holds against the REAL drivers and selftest:autopilot-stream-driver pins it with a positive control (without the fix the same hanging turn runs 30 s instead of 500 ms).

Three consequences. A turn started near the deadline is capped rather than left to the stall timeout — and on --driver bg that cap now covers the WHOLE turn, not just its measured phase (kzr6x): waitForIdle, stopAndSettle, spawnBackground and waitForSessionId each used to run their own timeout and ignore the caller, so a wrap-up carrying seconds of remaining budget could spend twenty minutes in waitForIdle first. One deadline is derived per turn and every phase is tightened to what is left of it; a phase whose budget is already gone is refused with cause: 'cap' rather than started on its own clock. A finish turn whose grace is already spent is skipped, and the unfinished task hands off as it stands — the goal file's last ticked milestone is what the next session resumes from, so nothing is lost; an autopilot:decision event with phase: grace-spent records the skip. And the inactivity-recovery wrap-up turn, which carries its own 5-minute cap, is capped by whichever is tighter and skipped outright past the grace, so it cannot buy a further five minutes. --grace 0 is legal and makes the deadline itself the bound. A run PAUSED on the subscription quota (--on-quota wait) is not a running turn; the existing budget check ends the run at the pause instead.

Batching: one turn, N disjoint tasks, one worktree agent each (kg9ny). --batch 1 is the default and unchanged. Above 1 the driven session stops being an executor and becomes an orchestrator:

cyberine autopilot --dry-run --batch 3     # see the partition before spending anything
cyberine autopilot --batch 2 --max-sessions 1 --max-hours 1

The roster is chosen by decideBatch() (src/autopilot/boundaryPolicy.ts), which grows decide()'s single pick into an ordered batch and never changes WHETHER to work or WHICH task leads. Three exclusions, because undeclared coupling is what a naive N-in-order hits:

  • An exclusive task takes a batch alone. A do: that runs cyberine autopilot, npm run sweep or cyberine run needs the working tree to itself — this repo's vibe rule forbids editing src/ while a run is alive, and the sweep marks a dirty tree's stamp not-ok on purpose. Autopilot FLAGS count as the same signal: measured on this board 2026-09-09, kj8y2's do: says "Run the same shape with ... --max-sessions 2" and "--bg --driver bg" and never the words "cyberine autopilot", so a command-name-only rule left it batchable.
  • At most one task per area:. Two tasks sharing an area are assumed to touch the same code even when neither names a file. An untagged task is never excluded by this rule — the tag is evidence of coupling, its absence is not evidence of independence.
  • A src/ path named by two picked tasks defers the second. The match is deliberately narrow (a src/ prefix, no globbing, no resolution): a false match defers a task by one batch, a missed match puts two worktree agents on one file and produces a merge conflict the loop cannot resolve.

A dependency can never share a batch with its dependent and that needs no rule: runnable only holds tasks whose deps are all done, so a task depending on another runnable task is by construction not in the set. The invariant is asserted in the smoke rather than implemented.

The batch prompt (prompts.ts v1.3.0) hands the session a CLOSED roster, tells it to spawn one isolation: "worktree" agent per task CONCURRENTLY, and names an explicit --model per spawn because the operator's agent-model-guard hook denies a spawn carrying none. Each finished task gets its own DONE <id> line; the loop accepts them in any order and across turns, ignores an id that is not on the roster, and retires the batch only when EVERY id is done or the board already carries it — a partly-finished batch keeps working, and the next nudge names only what is still open. run.json records a phase: "batch-open" decision event with the roster, and every turn event carries roster and pending.

Subagent spend is measured, not assumed free (kg9ny). contextTokens is the TOP-LEVEL context by design — it is what the scheduling tiers compare against — so a fan-out turn used to look free however many agents it spawned. AutopilotTurnRecord carries subagentTokens: the sum of input + cache_read + cache_creation over every model call the turn's subagents made. It is subtracted from the quota budget in decide() (never from the context budget, which measures a real window), and cyberine tasks calibrate reports it per task beside contextDelta. Since 2026-09-09 all three drivers record it, as a per-turn delta read from Claude Code's subagent transcripts — ~/.claude/projects/<slug>/<sessionId>/subagents/agent-<id>.jsonl (src/autopilot/subagentUsage.ts, guarded by npm run selftest:subagent-usage). The earlier reader summed isSidechain records in the PARENT transcript and read 0 on every real run: on Claude Code 2.1.266 a worktree subagent writes no record there at all.

The first live figure, measured on a 3-task fixture board with --batch 2 and two haiku agents each writing one file: 1,093,284 tokens under --driver bg and 1,532,923 under print (most of the latter were permission-denial retries) against an orchestrating context of ~50k — two trivial tasks cost 20-30x the session that dispatched them. The full write-up is .local/measurements/batch-subagent-tokens-2026-09-09.md.

Every driver now passes --dangerously-skip-permissions. Until 2026-09-09 only stream did; print (the foreground default) and bg carried just --permission-prompts none, so under a global defaultMode: auto every git commit, npm run and cd ... && git status in the driven session and its worktree agents was auto-denied with "no approval surface", and the 2026-09-08 real runs committed only because --bg defaults to stream. The flag is also carried on a --bg --resume follow-up so a resumed session cannot fall back to prompting.

The board wins over a NO_RUNNABLE reply (k488f). The sentinel is model output; the board is on disk. When a driven session answers NO_RUNNABLE while an exec: agent task is still runnable (other than ones that same reply finished with a DONE <id> line), the loop records a sentinel-contradicted decision event naming the ids and continues with the normal board decision instead of ending the run. A second contradiction in the same session hands off, so a session that keeps refusing is replaced by a fresh bootstrap rather than nudged forever. Found on the 2026-09-09 fixture run, where a haiku orchestrator blocked by permission denials answered NO_RUNNABLE with a task still [todo] and the run ended "drained" with nothing committed.

--batch N refuses --driver bg (k0x80). Each bg turn is a claude --bg --resume <sid>, and background agents spawned in the previous turn come back as "checkpointed for the background fork but could not be resumed", after which the orchestrator idles to the stall kill (measured 2026-09-09, run c8646c81: turn 4 spent 20 minutes on nothing). So cyberine autopilot --batch 2 --driver bg exits 2 at parse time with a reason naming the incompatibility — --dry-run included — and fan-out goes through --driver stream (one steerable child per session; the resume that kills the agents never happens there). npm run selftest:autopilot-batch-bg-refusal pins it. Lifting the refusal means teaching the bg driver to wait for the session's live background agents before it resumes; that is the deferred half.

Known Limitations and Deferred Work. The area:-collision rule could not be discriminated on this repo's own board either: exactly ONE runnable task carries area: autopilot, so the plan satisfying "never puts two together" is trivially true there and the rule is only exercised by the smoke. The file rule reads do: PROSE, so a task that touches a file without naming it is invisible to it — worktree isolation plus the merge-and-verify step is what actually catches that, not the roster. And --batch composes with --driver stream/bg only as far as the model tier allows: a batch whose tasks declare different tiers still leads on the lead's tier, since a session carries one --model.

Drafting the next batch when the board drains (kwzd6). The chat -> converge -> autopilot loop used to break at exactly one point: the board runs out of agent-runnable work, autopilot exits, and the operator has to converge the next batch by hand before anything can run again. --on-drained brainstorm closes that gap with ONE bounded session:

cyberine autopilot --on-drained brainstorm --brainstorm-max 3

It fires when decide() returns no-runnable — at session open or after a session finished the last task — and it drafts, it does not decide. The prompt (prompts.ts v1.2.0) fixes the evidence list so two brainstorms on the same board read the same inputs: docs/FEATURES.md (what already shipped), every Known Limitations and Deferred Work block in this file (boundaries the authors already named), .local/vibe/ goal files including their ## Lessons, the board with its blocker reasons, git log --since="7 days ago", and the newest handoff. Each draft is written with cyberine tasks add ... --status backlog --created-by autopilot-brainstorm --why <the evidence>, and the session ends with BRAINSTORMED <ids|none>, which the run reports as brainstormedIds and in its final notification.

The promote gate is the point. Auto-promotion would let the loop draft work for itself and burn the operator's quota in a closed circle, so promoting a draft to todo stays a human act. Three layers, and the middle one was added after an adversarial review showed the gate had been prompt-deep only:

  1. The prompt forbids cyberine tasks status outright and requires --status backlog on every add. This is instruction text, and holds only while the model obeys it.
  2. CYBERINE_TASKS_NO_PROMOTE=1, set by cyberine autopilot in the brainstorm session's spawn environment ONLY. Under it cyberine tasks REFUSES any status of todo or doing — from tasks status, from tasks add --status todo, and from tasks add with the flag omitted (the default is todo, which was the easiest bypass). Every other caller, including the operator's own shell and an ordinary driven task session, is untouched.
  3. WRITABLE_STATUSES caps tasks add --status at todo|backlog for every caller, so no new block can be created as doing, done or forbidden (that last is a ToS tombstone in this manifest, see T42-T44).

Before layer 2, a drafting agent could promote by omitting a flag, and the "never promotes" claim rested on the prompt alone.

Bounded four ways: one session per invocation, --brainstorm-max drafts (default 5), --brainstorm-max-turns worth of turns (default 5, then it stops even without the sentinel), and the same wall-clock cap every other turn gets. A spent budget or a STOP file refuses the session outright — drafting is a spend, and the budget is a spend limit. No handoff is written for a drafting session, and an autopilot:decision event with phase: brainstorm-open records the decision with the model and the draft ceiling. --dry-run says whether it would fire and, on a board that is ALREADY drained, that it would fire now.

Known Limitations and Deferred Work. No live brainstorm run has been made: the behaviour is pinned by the loop smoke against a scripted driver, and the 7d subscription window stood at 79% when this shipped, so the "one real run on a drained fixture board produces drafts under a bounded cost recorded in autopilot.jsonl" half of the task is NOT done. The draft QUALITY is therefore also unmeasured — the prompt names its evidence, but whether a real session cites it faithfully has not been observed. --brainstorm-max-turns has no CLI flag yet (it is an AutopilotConfig field the smoke drives); the default of 5 is what a real run gets. And the loop reads the ids only from the sentinel line, so a session that writes tasks and then fails to emit the line reports zero drafts while the board really did gain them — cyberine tasks --all is the check.

Audit. Every run is a normal RunStore run (workflowName: 'autopilot'), so cyberine agents and the web dashboard list it like any other. events.jsonl carries autopilot:decision (one per policy call: phases session-open, turn, stop-file, budget, handoff-plan) and autopilot:session (one per session, with turns, tokens, cost and the handoff path), each with the payload as a JSON detail. The session driver's own per-turn records land in the same run dir as autopilot.jsonl, stamped with the taskId the turn was working on — which is exactly what cyberine tasks calibrate reads back later.

Notifications. Every boundary fires THREE channels, each degrading independently: the Cyberine Stop hook (~/.cyberine/hooks.json / .cyberine/hooks.json), CYBERINE_ALERT_WEBHOOK_URL, and — since k6p4c — a first-party Telegram channel configured by env only: CYBERINE_TELEGRAM_BOT_TOKEN + CYBERINE_TELEGRAM_CHAT_ID (optional CYBERINE_TELEGRAM_THREAD_ID for a forum topic, CYBERINE_TELEGRAM_PARSE_MODE, and CYBERINE_TELEGRAM_API_BASE for a stub in tests). The Telegram channel posts the already-formatted message as text to sendMessage (truncated below 4096 with an ellipsis, no parse mode by default because handoff paths carry _ and *), through the same bounded retry as the webhook (retry on 5xx/429 only, never on 400), and the token never reaches a log line or a thrown error. Every message names the session index, the task id, the fill percent, the tokens, the cost so far, and the handoff path when there is one. A channel that is not configured degrades to a logged line, never a crash — --dry-run prints a telegram line saying whether it is configured. Both Telegram paths can coexist; a machine that already wires a Telegram sender through the Stop hook and ALSO sets the two env vars will get every ping twice, so pick one.

Spawn environment — the subscription wins over a metered key. src/core/config.ts loads the repo's .env into process.env, and Claude Code treats a present ANTHROPIC_API_KEY as THE auth source, ignoring the claude.ai login. So autopilot builds one environment for everything it spawns (the driven claude session AND the handoff writer — src/autopilot/spawnEnv.ts) and removes the metered key through the same meteredEnvToScrubForSub rule CliAgentAdapter already applies: scrubbed only when a subscription login is actually present, kept when the key is the sole auth. --backend codex additionally scrubs OPENAI_API_KEY for the writer. --dry-run prints the env scrub line so the choice is visible before anything starts. The first real run on 2026-09-06 is why this exists: with the key inherited, every turn came back Credit balance is too low in ~630ms with zero tokens, and the handoff died of the same key.

Error-turn breaker. A turn whose result event carries is_error counts toward a streak; three consecutive error turns end the session as failed (exit 1, one autopilot-failed notification naming the CLI's error text, NO handoff attempt — there is nothing to hand off and the writer would hit the same fault). Any successful turn resets the streak. Without it the same 2026-09-06 run spun to the 40-turn ceiling in ten minutes and then tried to hand off an empty session.

Quota pause (kp5x7). A turn is classified BEFORE it counts toward the breaker: classifyTurnFailure() calls it a QUOTA turn when the turn's rate_limit_event says rejected or its result text matches the rate-limit vocabulary classifyQuotaError already knows (429, too many requests, rate limit, ...). A quota turn never feeds the 3-strike streak. What happens next is --on-quota: wait (default) sends ONE autopilot-paused notification naming the reset time (hook tag [paused]), then sleeps until resetsAt plus up to a minute of jitter when the event reported one, else backs off 5, 10, 20, 30, 30... minutes — never busy-polling, every probe is a request against the same exhausted window; the sleep runs in 60-second slices so the STOP file is honoured within a minute; then it spends one sonnet probe turn ("Reply with exactly: ok") on the SAME session and, once the probe comes back allowed, resumes the interrupted task with the normal nudge prompt (streak reset). A probe that fails for a NON-quota reason (auth, network) is an error turn and goes to the breaker as before. stop hands off immediately and exits 0 with reason quota. api re-creates the driver on the same session id with the scrubbed ANTHROPIC_API_KEY restored; run.json records autopilotMeteredFromTurn so cyberine tasks calibrate and the billing view can separate subscription cost from metered cost. --max-hours is the outer deadline of the pause: when it expires while paused the session hands off (a completed turn always exists by then) and the run exits limit with a message naming the reset time; the wait is clamped to the remaining budget so the loop never sleeps past its own deadline.

Per-task model tier (ke8hm, k8bm4). Every board task may carry model: haiku | sonnet | opus | fable (cyberine tasks add --model, the tier the subagent-model-tier rubric judged it needs). Precedence, resolved in one place (src/autopilot/modelTier.ts) that both the plan and the run call: --force-model pins --model for every task; otherwise the task's own tier wins; otherwise --model; otherwise the CLI's own default answers. fable is a real alias on the installed CLI (claude --help, v2.1.263). TurnResult.model and the autopilot.jsonl model column report what actually answered, so a tier the CLI mapped elsewhere is visible per turn. The quota probe turn asks for sonnet.

How the tier reaches the model depends on the driver, and the difference is the operator's cost lever (k8bm4). A session is SPAWNED with the tier of the task it opens — that is the only moment stream and bg can be given a model at all, because a streaming session carries the one --model it was spawned with and a background job replays the options it was created with. So:

  • --driver print spawns a fresh child per turn and switches model per task inside one session (claude -p --resume <sid> --model <m> accepts a different model on a resumed session, so no context is thrown away; keep5 case 11 asserts the session id stays bound across the switch).
  • --driver stream / --driver bg carry ONE model per session. When the next task's effective model differs, the session ends at that task boundary and the next session opens on the new tier. The split is recorded in run.json's decision events as phase: "model-tier-boundary" naming both models, and it consumes one --max-sessions slot.

--dry-run states the cost up front: a task models line naming, per runnable task IN THE ORDER THE POLICY WILL TAKE THEM, the model it will get and why (task model:, --model, --force-model, cli default), plus a model boundaries line that for a one-model-per-session driver lists the tier groups and how many of the session budget they need. --json carries the same as taskModels[] and modelBoundaries. Before k8bm4 the plan printed the intended tier and the runtime discarded it: run 3fed0a2b ran all six of its model: sonnet tasks on the CLI default (opus) under --driver bg, and before ke8hm run f4249d5d took kxvc9 (drafted model: opus) in a --model sonnet session.

Per-task reasoning effort (k71d0). A task's reasoning: low | medium | high | xhigh | max (cyberine tasks add --reasoning, exec: agent only) reaches the session as claude --effort <level>, the same five values the installed CLI accepts. It travels exactly like the model tier: the print driver passes it per turn, stream and bg pass it once at spawn, and a change of EITHER the model or the effort ends a one-model-per-session session at the task boundary (the model-tier-boundary event now also names sessionEffort / nextEffort). --force-model pins the model only; the task's effort still applies. A task with no reasoning: passes no --effort, and an unset effort never shares a session with a set one.

cyberine tasks set k1abc --reasoning high     # the next autopilot session for k1abc spawns with --effort high
cyberine autopilot --dry-run                  # task models shows "k1abc sonnet effort high (task model:)"

--dry-run's task models and model boundaries lines print the effort next to the model, and --json adds effort to every taskModels[] entry and every modelBoundaries.groups[] entry.

Known limitations: there is no autopilot-wide --reasoning flag (the board field is the lever), and the batch roster (--batch) does not group by effort, the same as it does not group by model today.

Cost figures are API-list-price-equivalent, not money charged (k8bm4). costUsd in autopilot.jsonl, the per-session lines the wrapper prints, and the cost $… field in every notification all come from the CLI's total_cost_usd, which prices the turn's tokens at API list rate. A subscription login is not billed by it. Run 3fed0a2b printed cost $212.03 and was read as an invoice; the number is now labelled everywhere it appears.

Session names. Every driven turn is spawned with --name cyberine-ap-<runId8>-s<N> (N = the session index). --name applies in print mode: the process registers under that name in ~/.claude/sessions/<pid>.json (so ListAgents and peer SendMessage see cyberine-ap-0dec102d-s2 instead of a derived ai-agent-solution-e2), and the CLI writes a custom-title record into the session transcript — which is where amemoria reads the label for its own Telegram pings, so the handoff pings say [cyberine-ap-0dec102d-s2] instead of [unnamed]. The shape deliberately ends in -s<N>: the operator's cc_session lineage treats _<digits> and -<3+ digits> tails as its own and would misroute peer messages to a duplicate address. A session driven by the DEFAULT print driver does NOT appear in the Claude Code fleet view (claude agents): that view renders daemon-supervised --bg jobs only, and --bg is incompatible with --print/stream-json. --driver bg (below) is the driver that does put it there.

--bg carries the driver you asked for (k378p). --bg wraps the run in a claude --bg --exec fleet row that re-invokes this checkout with --attached. Until 2026-09-08 that wrapper stripped any --driver the operator passed and appended --driver stream unconditionally, so cyberine autopilot --bg --driver bg silently ran the stream driver. The chosen driver now rides into the row; stream remains the DEFAULT under --bg (nothing else steers a PTY row), and print passes through too — it drives fine there, it simply has no inject() seam, which the attached runtime already says once in the log. The launch output names it, so the choice is auditable without reading the row:

runId:  aca742b5-6626-4eba-a10b-74d605d7081c
fleet:  a33e99b6
driver: bg
open:   claude attach cyberine-ap-aca742b5-s1

With --driver bg the driven session is its own attachable job, so claude agents shows TWO rows — the wrapper cyberine-ap-<runId8> and the session cyberine-ap-<runId8>-s1. With --driver stream there is one row and the session is read with claude --resume <sessionId> --fork-session (the id comes from cyberine agents or autopilot.jsonl); forking rather than resuming leaves the live session's own transcript alone. Measured live 2026-09-08 on an isolated fixture board: cyberine autopilot --bg --driver bg --max-sessions 1 --max-hours 1 produced fleet row 4ab7be86 and session row cyberine-ap-7b023d8c-s1 (id b92ae4a8, session id b92ae4a8-cd9b-498f-9d04-5596af4ced13), whose attached log opens driver=bg; an earlier run of the same shape was re-opened afterwards with claude -p --resume <sessionId>, so the row is a real resumable session and not just a name. Full transcript, including two failures that were fixture artefacts rather than product faults, in .local/measurements/k378p-bg-driver-passthrough.md. claude attach itself needs a TTY and stays the operator's step.

Measurement. contextTokens is the LAST model call's context read from its assistant event, not result.usage — the result's usage is the turn TOTAL summed over every call, which on the first real run reported 9.8M tokens for a 195k session and drove a 986% "fill" handoff at what was really 19.6%. autopilot.jsonl rows now also carry modelCalls and contextSource; the session-driver section below has the probe numbers.

Run recipe for the first real run. Use a repo that has run cyberine init --rules (.cyberine/config.json carries rules.applied === true), and a board whose runnable tasks carry exec: agent and effort:. Start with cyberine autopilot --dry-run and read the chosen task, the excluded list and the lints; then run it bounded (cyberine autopilot --max-sessions 2 --max-hours 1) and expect one Telegram or webhook message per session boundary. The driven agent does its own commits under the repo's own rules; autopilot never runs a git mutation itself.

Verify:

npm run selftest:autopilot-loop

Cases (a)-(f) drive the real state machine with a scripted driver, a mutable fake board, a fake handoff, a collecting notifier and a fake clock — one task ending in DONE with the board drained, a first task ending at 70% fill so the session hands off with the second task as nextTaskId and the next session bootstraps on that handoff path, a STOP file appearing before turn 2, the NO_RUNNABLE sentinel, --max-sessions 1 reaching the limit, and a handoff that times out (exit 1, FAIL notification, session event still appended). Cases (g)-(h) spawn the real CLI in a throwaway git repo for the rules-not-applied refusal and the --dry-run plan (asserting no session driver was spawned and no run directory created); case (h2) covers the handoff-writer gate under --dry-run — a CYBERINE_HANDOFF_WRITER_BIN pointing at a missing file exits 2, names both that path and the builtin path, and writes NOTHING to stdout, with a positive control that the same command against an executable stub prints the plan and carries handoffWriter: { bin, ok, writer } in --json. It also pins the resolver's truth table and greps src/ to assert the builtin path is built in exactly one file (positive controls: re-wrapping the gate in if (!dryRun) reddens four assertions; adding the literal to a second file reddens the drift guard). Case (i) drives the real handoff bridge against a stub binary that refuses. Case (j) feeds eight scripted error turns and asserts the breaker stops at three with no handoff, then that two errors followed by a good turn reset the streak; with the breaker threshold lifted the same script runs to the 40-turn ceiling (positive control). Case (k) covers the spawn environment as a pure function: the driver key is scrubbed, --backend codex adds OPENAI_API_KEY, everything else and the injected run id survive, and an empty scrub list (no subscription found) leaves the key in place. Positive control: replacing the turn verdict's done with a literal false fails case (b) — the finished session then spends a finish-then-handoff turn re-nudging the first task and plans the handoff with the wrong nextTaskId; restoring it is green again.

Quota (kxvc9). Autopilot runs on the operator's Claude Code SUBSCRIPTION (the driven claude sees no ANTHROPIC_API_KEY), which has a rolling 5-hour window and a weekly cap. There is no headless "remaining usage" readout, so the signal is assembled from the CLI's own rate_limit_event (see docs/providers/claude-code-cli.md): every real --driver print and --driver stream turn carries one and the driver records it as rateLimit on the autopilot.jsonl row (status, resetsAt in epoch seconds, rateLimitType, fiveHourUtilization, sevenDayUtilization). A --driver bg turn carries NONE and instead re-runs the probe at its own boundaries — see the --driver bg section below (kp5m0). Before the first session a cheap probe turn refreshes it; the newest reading is cached in BOTH .local/autopilot/quota.json and $CYBERINE_HOME/quota/claude-<accountKey>.json. Since 2026-09-11 (kh6hf) that global copy is keyed by ACCOUNT, not shared by all of them: accountKey is a 12-hex fingerprint of the CLAUDE_CODE_OAUTH_TOKEN in the spawn env when a seat is pinned, else of the logged-in oauthAccount in ~/.claude.json (CLAUDE_CONFIG_DIR honoured) — the raw token, uuid and email are hashed, never stored. A reading is served only to the account that produced it, and a reading whose account cannot be identified (no key derivable, or a pre-kh6hf record) is treated as NO reading rather than as fact, because one global file previously served every seat on the machine and a run under one account was sized from another's number. Percentages are also DIRECTED: the line reads 7d 39% used, 61% left (resets in 5d9h), since utilization is the CONSUMED fraction and an undirected 39% was read as remaining at least once. And a reading older than 30 minutes (QUOTA_PRESENTATION_MAX_AGE_MS) is refused at presentation instead of being rendered with a freshly-recomputed countdown: the line reads no usable reading: the last one is 1d4h old, past the 30m bound — a real run re-probes on start, and --dry-run withholds it from the sizing policy too, so it can never advise from a number it will not show. Three effects: (1) --dry-run prints a quota line from the cache and never probes — and since 2026-09-09 (kg1s7) a cached window whose resetsAt has already passed is EXPIRED, not live: the line reads 7d reset since observed (was 79% used) and that window no longer binds the first verdict (the dry-run used to refuse a fresh window on an 18-hour-old 79%); (2) a real run REFUSES (exit 2, reset time in the message) when the last known status is rejected and resetsAt is still in the future; (3) boundaryPolicy.decide() takes the most-binding window's utilization as a SECOND budget in windowTokens units — (1 - utilization) * windowTokens - handoffReserve — so a task whose calibrated estimate does not fit the remaining quota is not started even when the context alone would afford it, and the reason string names the quota bind. The number is re-read after every print/stream turn from that turn's own event, and re-PROBED at a bg run's session and turn boundaries, so under every driver the policy sees the window drain during the run. cyberine autopilot --dry-run prints which of the two a given --driver gets as its quota signal line (and quotaSignal under --json). It is a heuristic, not a token-accurate conversion (Anthropic's limit is a usage quota, not a token count); it fails toward caution, and a declined task simply runs once resetsAt passes. allowed_warning never hard-stops on its own. The Codex backend exposes no comparable event: its quota is "unknown", never guessed. Mid-run exhaustion is kp5x7's pause, not this.

Known Limitations and Deferred Work.

  • claude-only driver. sessionDriver.ts drives claude and nothing else, so --model is a Claude Code model alias/id. --backend only picks which backend writes the HANDOFF, not who does the work.
  • No mid-turn interruption. Turns are atomic by construction (the measurement only exists once the CLI emits its result event), so the STOP file, the emergency tier and --max-hours all take effect at the next turn boundary, never inside one. --max-hours therefore bounds when work is STARTED, not when the process exits: a turn in flight when the budget expires runs to completion, and an unfinished task is granted one finish turn before the handoff.
  • Needs the Cyberine rules applied (cyberine init --rules). That is a refusal, not a fallback. There is a built-in handoff writer (since k73tg, and the only one since 2026-09-30), so no external skill is needed.
  • The live proof used a STUB writer (then an amemoria stub). Running the real offload would have written into the operator's own memory repo, so the shipped evidence covers the loop, the board flip, the stub handoff and the second session's bootstrap. The first real run (2026-09-06) then found the two faults the stub could not: the inherited ANTHROPIC_API_KEY and the absence of an error-turn breaker, both fixed above.
  • The breaker threshold is fixed at three. There is no flag or env var for it; a CLI-level error is not something more patience fixes.
  • A mid-turn quota hit has not been observed live. The classifier handles both shapes the CLI can produce — a rate_limit_event with status: rejected, or an is_error result whose text carries the rate-limit vocabulary — but whether a limit reached MID-turn ends that turn with is_error (it should: the API call fails) is recorded in docs/providers/claude-code-cli.md as unverified until a real window runs out under autopilot. The probe model is sonnet; on a plan whose binding window is model-specific (seven_day_opus) a sonnet probe can still come back allowed while the session's own model is limited, in which case the resumed turn is classified quota again and the pause simply continues.
  • A killed turn (stall or cap) gets ONE wrap-up turn, then the session hands off (ksn4d). The driver still adopts no session id from a turn that produced no result event, but it remembers the id system/init announced as observedSessionId (on the timeout error and via driver.observedSessionId()), and the loop resumes THAT session once with buildWrapUpPrompt (prompt v1.1.0): stop working, commit coherent work under the repo's rules or git stash push -u -m "autopilot partial <id>" where a commit is not pre-authorized, reply DONE <id> only if the task is verifiably complete, else one status line; no new work. The wrap-up is capped at 5 minutes regardless of --turn-timeout, is recorded in autopilot.jsonl with wrapUp: true (so cyberine tasks calibrate can exclude it), and its result feeds the normal handoff. A second kill on the wrap-up itself, a kill before system/init arrived, or a STOP file present at that moment still ends the session failed exactly as before. Cyberine itself never runs a git mutation — the agent does, under its own rules. (On 2026-09-06 session 2 lost a real 20-minute kmq07 turn this way, leaving 271 uncommitted lines the operator had to stash by hand.) Since khgxt a turn is no longer killed for running long: it is killed only when BOTH liveness signals go quiet for --stall-timeout — no stream-json event on stdout AND no descendant process that is younger than the window or consumed CPU since the last 15 s poll (a sweep or build inside a Bash tool call emits nothing for minutes while its subprocess is busy; a stuck ssh/docker consumes nothing, which is what separates it). MCP servers the CLI spawns at turn start are excluded from the "young" test so they cannot keep a hung turn alive. --turn-timeout survives only as an opt-in cap. Every autopilot.jsonl row carries idleMsMax, the longest quiet gap the turn showed, so cyberine tasks calibrate can show how close healthy turns get to the threshold before anyone tunes it.
  • A whole task in one turn means one decision point per task. The boundary policy is asked after every turn, but the agent's own turn ends when the task ends, so continue/nudge mostly fire at task boundaries anyway. That matches the "decide at TASK boundaries" rule, but it also means the fill measured mid-task is never seen — the emergency tier can only react after the fact.
  • A writer that dies AFTER offload returned 0 surfaces in seconds — IF the writer writes its status marker (kcmnj). The bridge still watches for a FILE as the success signal. It now also reads the log: <path> line the offload prints on stdout and watches for <path>.status.json ({ "exit": N }) next to it: a non-zero exit with no handoff file fails at once with a typed HandoffWriterDiedError naming the writer's own log; exit 0 with no file fails after two poll intervals with the same class. The amemoria skill wrote that marker since 2026-09-07 (~/.claude e03c3d02: {"exit":$EXIT} to <log>.status.json right after EXIT=$? in the detached wrapper), so a writer that dies is seen within one poll instead of the 10-minute HandoffTimeoutError the 2026-09-06 run paid (exit 1 after 6 s, ten idle minutes). A writer without the marker degrades to that timeout, and the deterministic builtin does not write one. The bridge never parses the writer's log lines — that format belongs to the writer.
  • The driven agent's commits are governed by the repo's rules, not by autopilot. Autopilot never stages, commits, pushes or merges; it only starts sessions and writes its own audit.
  • A runaway session is capped by turn count, not by anything smarter. A driven agent that never says DONE and whose context never grows is handed off after 40 turns (DEFAULT_MAX_TURNS_PER_SESSION), which is a backstop rather than an operating limit.
  • --bg re-spawns the command through npx tsx and prints the run id it pre-allocated (CYBERINE_AUTOPILOT_RUN_ID); there is no cyberine autopilot status — read the log path it printed, or cyberine agents. Since kwx2r that re-spawn is wrapped in a claude --bg --exec fleet row by default, so claude agents shows it too — see "Steer it from the fleet" below.

Session driver — --driver print | stream | bg (k59px, kddqh). print is the default and is unchanged: one claude -p [--resume <sid>] child per turn, stdin closed, the turn's result event measured and appended to autopilot.jsonl. --driver stream keeps every one of those numbers and moves the process boundary: ONE child per SESSION, spawned as

claude [--resume <sid>] -p \
  --input-format stream-json --output-format stream-json --verbose \
  --permission-prompts none --dangerously-skip-permissions \
  [--model <model>] --name cyberine-ap-<runId8>-s<N>

with stdin PIPED — the only Cyberine spawn that keeps stdin open. Each loop prompt is written as one {"type":"user","message":{"role":"user","content":"..."}} line, and EVERY top-level result the child emits becomes one AutopilotTurnRecord with the same contextTokens rule (the LAST top-level assistant event's input + cache_read + cache_creation, scoped to that turn's slice of the stream). There is never a positional prompt in the argv, because --tools / --allowedTools / --add-dir are variadic and would swallow one; --verbose is mandatory, the CLI refuses stream-json output without it.

Steer it with SendMessage. A streaming child registers in ~/.claude/sessions/<pid>.json as kind: interactive, entrypoint: sdk-cli, WITH a messagingSocketPath — so any Claude Code session on the same machine can address it by the --name autopilot already gives it and inject a turn while the loop is between turns:

cyberine autopilot --driver stream --max-sessions 1 --model haiku

# from any other Claude Code session on this machine:
#   ListAgents                 -> find the row named cyberine-ap-<runId8>-s1
#   SendMessage to that name   -> lands as a new user turn in the SAME session

A result the loop did not ask for — a peer SendMessage, or the library-level inject(text) — is recorded as an OPERATOR turn: origin: "operator", resumed: true, and deliberately NO taskId, so cyberine tasks calibrate treats it as a group break rather than as effort spent on the task. Loop turns carry origin: "loop". The field is additive and optional; print records do not carry it at all. Measured live on 2026-09-08 (claude 2.1.263, --model haiku, throwaway repo): one process, session id stable, contextTokens 64009 (loop) -> 64269 (peer SendMessage, origin: operator, promptChars: 0) -> 64494 (loop), and the session deregistered from ~/.claude/sessions/ on close().

Known Limitations and Deferred Work (--driver stream)

  • print is the default only for a foreground run. Under --bg the default is stream (kwx2r, k378p), and it has driven a full board unattended: run 3fed0a2b on 2026-09-08 worked six tasks in one session on this driver.
  • No CLI surface for inject() yet. It is a library seam on the driver (and an optional inject? on the AutopilotDriver port); the operator-facing channel today is SendMessage from another Claude Code session. A cyberine autopilot reply command is not built.
  • Per-turn --model is ignored. A streaming child carries the ONE --model it was spawned with; a different per-turn model (a task's model: tier, or the sonnet quota probe) is logged once on stderr and otherwise ignored. Consequence: under --driver stream the mid-run quota probe runs on the session's own model instead of sonnet. Changing a live session's model would mean hand-rolling the CLI's private control_request protocol, which this deliberately does not do.
  • It runs with --dangerously-skip-permissions, which --driver print does not: a streaming child registers as an INTERACTIVE session, so --permission-prompts none alone would auto-DENY every prompt the operator's settings do not already allow. Both flags are passed.
  • The streaming child itself is not a row in the claude agents fleet TABLE. That view renders daemon-supervised --bg jobs; an interactive/sdk-cli session appears in claude agents --json and ListAgents only. Under --bg the WRAPPER is a row (cyberine-ap-<runId8>, kwx2r) and peek replies reach the child through it; --driver bg is what makes the session itself a row.
  • A stalled turn kills the child. --stall-timeout / --turn-timeout mean exactly what they do under print, measured from the stdin write to the matching result. The loop's one wrap-up turn then respawns a child with --resume <observed session id>, which re-plays the transcript — the context that costs is real, not free.

Seeing an autopilot run's progress — terminal activity lines and the hub row (kyqm6)

Until this change a cyberine autopilot --engine claude turn printed nothing while it worked (a 17 minute first turn was silent), and the hub showed the run as Cli 0/0 with empty TOKENS and COST. Both sides now read what the run already produces.

Terminal. With the default --driver print, the driver reads the turn's stream-json as it arrives and prints one dim line per burst of tool use, at most one every 15 seconds, prefixed with the session and the task, for example [autopilot] session 1 task kb8qh · Edit src/tui/app.tsx · 34 tools · 6m12s. A tool call that keeps the stream silent (a long test run) gets a still on Bash npm run typecheck (46s in this call) line every 45 seconds, so a turn that is running tools is never quiet for more than about a minute. Only the tool name and one short target are shown: a file path (relative to the repo), the first line of a shell command, a search pattern, or a URL host. Every target goes through the secret redaction used for worker output plus a pass that masks key-shaped tokens and URL credentials before it is clipped, and an unknown or MCP tool shows its name alone. A full tool input is never printed. Under --json the lines go to stderr as the other [autopilot] lines do.

Hub row. cyberine autopilot now writes autopilot (progress and usage) onto the run's run.json after every session and turn decision. A live row reads autopilot: session 1 · task kb8qh, a finished one autopilot: 9 tasks done (an errored or cancelled one appends the reason), WORKFLOW reads Pilot instead of Cli, and STEPS shows the tasks finished (9✓) instead of 0/0. TOKENS and COST are filled, and the header totals include them: tokens are fresh input plus cache-creation plus output tokens, cache re-reads are kept out of the figure (they dwarf everything else and are not new work), and cost is the API-list-price-equivalent of those tokens, not money charged, exactly as the run's final summary line labels it. Probe turns are excluded so the run total equals the summary line's cost. AGE is unchanged: it is the time since the run's last update.

Detail. The wide hub's preview pane and the drill-in detail view (Enter on the run) show a timeline built from the run's autopilot:decision and autopilot:session events: session opens, each turn with the task it finished and the next one, the context fill, how long the turn took, and the session end with its outcome and cost, plus a usage block (tokens, API-list-price-equivalent cost, model time, cache reads). The events file is tailed by byte offset, only while the run is live, so a poll that finds it unchanged reads nothing.

cyberine autopilot --engine claude --max-sessions 2 --max-hours 1
cyberine agents

Known Limitations and Deferred Work. The activity lines exist for --driver print only; the stream and bg drivers stay silent between turns, as does --engine fleet, which also writes no progress record. A run recorded before this change has no autopilot field, so its row keeps the end-reason text with Pilot and - in the WORKFLOW and STEPS cells and empty TOKENS and COST; its detail view still shows the timeline, because the events were always recorded, but no usage block. The token figure is not the context size the final summary prints as context N (that is the last call's prompt, a per-session peak), so the two numbers differ on purpose. Tokens and cost update at each turn boundary, not during a turn. A batch turn counts a task as finished when the loop's own decision says so, the same rule as the summary.

Fleet-visible driver — cyberine autopilot --driver bg (kddqh)

--driver bg swaps the session driver (and nothing else: the loop, the boundary policy, the handoff bridge, the notifications and every autopilot.jsonl consumer are untouched; --driver print stays the default and behaves exactly as before). Instead of a fresh claude -p --resume child per turn, it spawns ONE daemon-supervised background job and steers it:

  • Spawn, once per session: claude "<bootstrap prompt>" --bg --name cyberine-ap-<runId8>-s<N> [--model <m>] --permission-prompts none. The prompt is the FIRST argv element on purpose — several Claude Code flags are variadic (--tools, --add-dir), and a trailing positional gets swallowed as one of their values. --add-dir is not passed: the job already runs in the repo root.
  • Every follow-up: claude stop <jobId>, then claude "<prompt>" --bg --resume <sessionId> with no other flags. Measured on claude 2.1.263: that exact form wakes the SAME job under the same short id and reuses its saved options; resuming a job that is still alive, or passing ANY flag, makes the CLI start a COPY and print note: session <id> is already running in the background, so this started a copy as <newid>.. The driver compares the id the resume printed against the id it owns; on a mismatch it stops AND removes the accidental copy and fails the turn with BgSessionDriverError code forked-copy rather than driving a session nobody is watching.
  • The stop must SETTLE before the resume, and claude stop returning is not that signal. Measured 2026-09-07: claude stop <id> returns, and the job's row in claude agents --json loses its pid field, roughly 0.6 s BEFORE the OS process actually exits — and the CLI's "already running" test sees that still-live process, so a resume fired immediately forks a copy every time (it did, twice, on the first live run of this driver). So the driver reads the row's pid BEFORE stopping, then waits for that pid to actually die (kill(pid, 0), bounded by stopTimeoutMs, default 60 s) plus a stopSettleMs grace (default 1 s) before resuming. That is what makes the follow-up land on the same id instead of a copy.
  • Turn boundaries come from claude agents --json --all (state goes working -> done/blocked) plus the session transcript's own system/turn_duration record. The driver never injects while the job is mid-turn.
  • Measurement reads ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl — the same invariant the print driver holds: the context is the LAST top-level assistant record's input + cache_read + cache_creation, never a sum over the turn's calls, and sidechain (subagent) records are skipped. The cwd is realpath-resolved first, because Claude Code names that folder from the process's own resolved cwd (on macOS /var/... is /private/var/...). The folder name is the harness's slug (every non-alphanumeric char becomes -) as a first guess; since kp95c the transcript is found by session id across ~/.claude/projects/*/ when that guess misses (a slug over 200 chars carries an undocumented hash suffix), and the subagent directory is taken from the folder the transcript was found in (src/autopilot/claudeTranscriptPath.ts, guarded by npm run selftest:claude-transcript-path). Field reading goes through readClaudeTurnTokens in src/adapters/claudeStreamJson.ts, so the two drivers cannot drift on it.
  • Turns the loop did not originate count. A peek reply from the fleet, a claude.ai Remote Control reply and a peer SendMessage all land in the same transcript as ordinary prompt records. Each complete one is measured, appended to autopilot.jsonl with origin: 'operator' (driven turns carry origin: 'loop'), counted by turnCount(), and its answer text is folded into the resultText the loop runs its DONE <taskId> check over.
  • close() runs claude stop <jobId> so an aborted autopilot never leaves an agent running unattended. It deliberately does NOT claude rm the job: the conversation stays openable with claude attach <id> and resumable with claude --resume <sessionId>.
  • A session boundary (kj8y2, measured 2026-09-09): -s2 really is a second, independent fleet row, and -s1 really is stopped, not left running. A two-fixture-task run forced across a session boundary (one task on haiku, the next on sonnet, so the model-tier split ends session 1 early) showed cyberine-ap-<run8>-s1's row flip to state: "stopped" with its pid field gone in the SAME poll the handoff log line appears, then cyberine-ap-<run8>-s2 appear as a brand-new row (its own fleet id, pid and Claude Code session id) the moment session 2 bootstraps. The handoff writer inherits no process or session state from the -s1 job — it is a fresh offload spawn — only the SEED TEXT the loop builds from session 1's own result (Where: ... last task <id> -- DONE <id> / Next: continue with task <id> on the board), and session 2's actual bootstrap prompt names that handoff file and tells the agent to read it first. Full measurement, including the fixture and the claude agents --json --all table per phase: .local/measurements/kj8y2-bg-driver-session-boundary.md.

Run recipe — steer it from the fleet:

# start a bounded run whose session shows up in the fleet view
cyberine autopilot --driver bg --max-sessions 1 --max-hours 1 --model haiku

# in another terminal: the driven session is a real row (name cyberine-ap-<runId8>-s1)
claude agents --json --all | jq '.[] | select(.name | startswith("cyberine-ap-"))'
claude agents                 # the TUI: arrows to the row, Space to peek, type a reply, Enter

# read what it is doing, or take the terminal over entirely
claude logs <jobId>
claude attach <jobId>

# the reply you typed lands in autopilot.jsonl as an operator turn, with its own measured context
jq -c 'select(.origin=="operator") | {turn,contextTokens,promptChars,resultChars}' \
  .local/runs/<runId>/autopilot.jsonl

# kp5m0: the quota reading MOVES across a bg run -- one probe row per refreshed boundary
jq -c 'select(.probe==true) | {turn, u: .rateLimit.fiveHourUtilization, at: .finishedAt}' \
  .local/runs/<runId>/autopilot.jsonl

# which driver keeps the reading live, before starting anything
cyberine autopilot --driver bg --dry-run | grep 'quota signal'

A reply the fleet cannot deliver right now (the job is stopped between turns) is SAVED by Claude Code and sent as that session's next prompt when it starts again, so a peek reply typed mid-nudge is not lost — it simply arrives one turn later and is measured there.

Known Limitations and Deferred Work (--driver bg).

  • No cost. A background job emits no result event, so there is no total_cost_usd. costUsd is undefined on every bg turn and the autopilot.jsonl column is absent rather than guessed — the repo's price table models neither cache-read nor cache-creation rates, and a wrong number is worse than none. Token counts, context and modelCalls are unaffected.
  • No rate_limit_event on a bg turn — the reading is PROBED, not carried (kp5m0). Scanned 2026-09-10 over every transcript under ~/.claude/projects: the background session's .jsonl has no rate-limit record TYPE at all (the string rate_limit_info appears there only inside message content), so a bg turn can never carry the reading the way a -p or stream turn does. Until kp5m0 that left the boundary policy's quota budget frozen at the pre-run probe for the whole run — the guard that stopped run 01d98073 at 79% could not fire a second time. The bg driver now re-runs the SAME cheap probe (probeQuota, one claude -p on sonnet, ~1 turn of the cheapest model) at every session boundary and at the first turn boundary that finds the last reading older than 10 minutes; a boundary whose reading is under 1 minute old spends nothing, so the preflight probe is never paid for twice. Each refresh writes through the same path a print-driver turn does: .local/autopilot/quota.json + $CYBERINE_HOME/quota/claude.json, and one probe: true row in autopilot.jsonl carrying rateLimit.fiveHourUtilization. A probe that fails, times out or reports nothing leaves the last reading standing and never fails the turn it precedes. Still deferred: the refresh is at BOUNDARIES, not continuous, so a window that fills inside one long turn is seen at the next boundary rather than the moment it happens; the intervals are constants in src/autopilot/quotaRefresh.ts, not flags or env vars; a probe runs BEFORE the turn's own wall-clock cap is converted to a deadline, so each refreshed boundary can push the run's maxHours bound out by up to the probe's 60 s ceiling (well inside the default 20-minute grace, but it is not free); and --on-quota wait still classifies a quota-blocked turn from the result TEXT, not from the reading.
  • --model and --permission-mode are frozen at spawn. A background session replays its own saved options, and passing a flag on resume forks a copy. So the per-task model: tier (ke8hm) cannot switch mid-session on this driver: the whole session runs the model it was spawned with, and a per-turn override is logged under --debug and ignored. autopilot.jsonl's model column still reports what actually answered, so the mismatch is visible per turn.
  • --on-quota api cannot swap the key mid-run. The metered restart re-creates the driver with a different environment, but a background job's environment was fixed when the daemon spawned it. The driver re-adopts the existing job by session id instead, which means the run keeps using the subscription; use --driver print when a metered fallback matters.
  • A stop + resume costs a few seconds per nudge (the stop settle alone is ~1.5 s) and re-reads the session from disk, where the print driver simply spawns the next -p child. On a task that needs many short nudges this is the slower driver.
  • An unapproved project MCP server blocks the job instead of failing it. Measured 2026-09-07: a first --bg run in a directory whose .mcp.json has never been trusted parks at blocked with 1 new MCP server needs approval, and --permission-prompts none does not cover that prompt. The driver reports it as a timeout naming the job state and pointing at claude logs <id>; the operator either approves it once from claude attach <id> or spawns with --strict-mcp-config.
  • A turn that stops mid-way at blocked is a needs-input failure, not a success (kakt9). Measured 2026-10-02 on Claude Code 2.1.287 with the driver's own argv: a model that calls AskUserQuestion mid-turn leaves the job at blocked (status waiting) with no turn_duration record, and --permission-prompts none does not answer it. The driver used to settle that unfinished turn as idle and the bg worker reported success; it now fails the turn with code needs-input, naming claude attach <id>. A completed turn whose row happens to read blocked still returns normally, and an interactive claude agents row (state null) carrying the same session id is never adopted on resume.
  • The private daemon files are never touched. ~/.claude/jobs/<id>/state.json, daemon/roster.json and the daemon control socket are read-only-by-inspection here; the driver talks to claude the CLI only (--bg, agents --json --all, stop, rm), which is what keeps it on a supported surface.

Verify:

npm run selftest:autopilot-bg-driver
npm run selftest:quota-preflight   # section (f) covers the kp5m0 bg quota refresh

Drives the real driver against a fake claude that mimics --bg (prints the real banner shape, registers a job, writes a transcript from a detached worker), agents --json --all, stop and rm. It pins the argv contract (prompt first; the follow-up is EXACTLY [prompt, --bg, --resume, <sid>]), that no prompt is ever injected while the job is working, that the .env scrub reaches the child, that the context is the LAST call and not the turn total (a subagent record carrying 999999 is proven inert), that an operator turn is recorded with origin: 'operator' and folded into resultText, that close() stops the job, and that a resume which forks a copy is stopped, removed and reported instead of driven. Positive control: corrupting the last TOP-LEVEL assistant usage moves the measured context by exactly the injected amount, while corrupting the last SIDECHAIN one moves it by nothing.

Steer it from the fleet (cyberine autopilot --bg, kwx2r). --bg no longer just detaches: it registers the run as a row in the claude agents fleet view, so the operator can watch progress and type into a running loop from the same place every other background session lives. There is no opt-in flag — the fleet row is the default and the old detached spawn is the fallback.

cyberine autopilot --bg --max-sessions 2 --model haiku
# runId:  03ccdb0c-f4c1-4e75-9148-f518828fd5c0
# fleet:  8a597539
# log:    .local/autopilot/logs/03ccdb0c-....log
# watch:  claude agents
# stop:   claude stop 8a597539

claude agents                       # the row is named cyberine-ap-<runId8>, kind: background
claude attach 8a597539              # type a line -> it becomes an operator turn in the live session
claude stop 8a597539                # SIGTERM: writes the stop record, then exits

What --bg actually does: it probes claude --version against the --exec row in CLI_FLAG_COMPAT (src/adapters/CliAgentAdapter.ts, known-good 2.1.263), then spawns claude --bg --name cyberine-ap-<runId8> --exec "<command>" where <command> re-invokes THIS checkout as CYBERINE_AUTOPILOT_RUN_ID=<runId> npx tsx --tsconfig <tsconfig> <src/cyberine.ts> autopilot --attached <your flags minus --bg> --driver stream, with cwd = the repo root and the same scrubbed environment the driven session gets. The short id is read off the backgrounded · <id> line after stripping ANSI (the client keeps its colours on a pipe). The run id rides IN the command string, not in the spawn environment: measured 2026-09-08, the daemon does NOT forward the client's extra env vars to an exec job, so without the prefix the attached child would invent its own run id and log somewhere the parent never printed. The same prefix carries CYBERINE_HANDOFF_WRITER_BIN and NF_TASKS_ROOT when the launching shell has them set (kdche, 2026-09-09; forwardableExecEnv in src/autopilot/fleetExec.ts) -- an operator override of either reaches the exec'd job the same way the run id does, instead of silently falling back to the builtin handoff writer or git-derived board root once inside the fleet row (the forwarded variable was CYBERINE_AMEMORIA_BIN until 2026-09-30).

--attached is that internal mode, and it is what makes the row steerable:

  • PTY stdin is an operator channel. Under --exec the process owns a real TTY, and a peek reply (Space in claude agents, then type + Enter) or claude attach <id> writes straight into it. Each non-empty line is trimmed and handed to the stream driver's inject(text), which lands it as an OPERATOR turn on the live session — origin: "operator", no taskId, exactly as SendMessage does. A line that arrives before a session exists is HELD and delivered at the next turn boundary rather than dropped. claude attach forwards the raw PTY, so an arrow key, Ctrl+C or Ctrl+R can land as a CSI/SS3 escape sequence or a lone control byte inside that line before Enter; onLine strips those (sanitizeOperatorLine in src/autopilot/attachedMode.ts) before treating the result as a turn, and a line that is empty after stripping produces no operator turn at all. Attach itself writes nothing to stdout — no hint, no greeting; the first write is the newline-guarded status line at the next turn boundary. claude attach's own key handling (Left/Right navigation back to the fleet view, for example) is outside this repo.
  • stdout is a status channel, one line per turn boundary. s<N> t<M> ctx <pct>% <taskId> · <the first 100 chars of the reply>, because the daemon shows the LAST stdout line as the row's detail column. A wait prints waiting: <reason> (quota preflight, quota pause, the offload handoff), so a loop that is blocked says so in the fleet view.
  • Every emitted line is printable ASCII plus the middle dot, and the first write is newline-guarded (kqkw2). The cyberine-ap-3fed0a2b row rendered its detail as <ESC>s1 t1 ctx 22% kx75n ... -- a bare ESC before the head. That byte was never in the line this process builds: the head is a plain template, the tail already goes through sanitizeStatusText (which replaces every code point below 0x20, ESC included), and emit() appends only a newline. npx tsx was measured on 2026-09-09 writing NO ESC to a piped stdout, which is what the daemon gives an --exec job, so the runner is not the source either; what remains is the daemon's own capture or the client that renders the row, both outside this repo. Two defences ship instead of a source fix: sanitizeStatusLine reduces the WHOLE line (head included) to [ -~·], and the first stdout write is prefixed with a newline so a foreign unterminated write cannot fuse its trailing bytes onto the front of ours. Known Limitations: the stray byte itself has NOT been re-observed since the fix -- confirming it needs a live --bg run, which was not made (the 7d subscription window was at 79%), so the ESC's true origin is still unproven and the fix is a contract on our own output, not a diagnosis.
  • A session opening prints immediately, before its first turn completes. session <N>/<maxSessions> · <taskId> · turn 1 running — otherwise the row detail stayed on the PREVIOUS session's stale line for the entire bootstrap turn (measured 2026-09-08: 35+ minutes of an actively-working, committing session read idle because nothing had reached stdout since the last quota-check waiting: line).
  • A heartbeat refreshes the row detail every 60 s of silence. Whichever status/waiting/session-open line printed last is re-emitted with the elapsed time appended (· 12m) if nothing new has printed for 60 s — a single unref'd timer inside the attached runtime, cleared the moment a real line prints or the runtime shuts down; it never keeps the process alive and never runs outside --attached mode.
  • The log file keeps everything. .local/autopilot/logs/<runId>.log receives the full loop log AND every line the command would otherwise have written to stderr; only the status lines reach stdout. claude logs <id> therefore shows the status stream, and the file shows the run.
  • One attached process per runId (kx75n). Before it reads the board, an --attached start claims .local/autopilot/locks/<runId>.json — a file written atomically (staged, then linked, so it never appears half-written) carrying the holder's pid and start time. A second start for the SAME CYBERINE_AUTOPILOT_RUN_ID while that pid is alive prints one line on stdout (autopilot: runId <id> is already driven by pid <pid> (since <ts>); this copy exits without touching the board) and exits 0 — no board read, no quota preflight, no driver spawn. A lock whose pid is gone is stale and is broken automatically, so an unclean kill never wedges the next run. The lock is released on exit, including the claude stop SIGTERM path. A run with no CYBERINE_AUTOPILOT_RUN_ID mints a fresh id and so can never collide.
  • claude stop <id> sends SIGTERM and SIGKILLs ~5 s later (measured 2026-09-08). The handler is synchronous inside that budget: it writes .local/autopilot/stopped/<runId>.json — run id, signal, session index, task, turn, context tokens, the log path and resume: claude --resume <sessionId> — flips the same in-memory stop flag the STOP file sets, and exits. It never tries to finish the model turn in flight; the driven session is resumed by its session id instead.

Fallback, announced on stderr as no claude fleet row (<reason>); falling back to a detached background process: claude missing or older than the known-good version, a --version that does not parse, or no backgrounded · line within 20 s (the attempt is killed first). The detached npx tsx spawn and its runId/stop/watch output are byte-identical to what --bg printed before.

Known Limitations and Deferred Work (autopilot --bg fleet row)

  • The row is never Needs input. The daemon derives working/idle from PTY output activity, so an autopilot that is waiting shows as working (or goes idle after ~30 s of quiet) and never as blocked. The waiting: <reason> line in the detail column is the only signal, which is why one is printed at every wait. A long-running TURN is a separate risk the session-open line plus the 60 s heartbeat (above) now cover: the row detail no longer goes stale-looking just because the driven agent is still working and has not finished a turn yet.
  • --exec is undocumented in claude --help on 2.1.263 (the agent-view doc is what specifies it). It is registered in CLI_FLAG_COMPAT with that known-good version so an older binary is refused BEFORE the spawn instead of diagnosed after it, but upstream could rename it without a deprecation.
  • The two claude respawn forms behave differently, and only one of them duplicates. claude respawn --all starts a SECOND copy of an exec row's command WITHOUT killing the first (operator-run probe 2026-09-08, k5qjr: two pids under one fleet id). claude respawn <id>, on a real cyberine autopilot --bg row, REPLACES instead: measured 2026-09-08 (ktj6p) on a throwaway checkout, the original attached pid was gone afterwards, exactly one attached process was left for that runId, the row stayed working, and the daemon left ONE cyberine-ap-* row. Do not generalise from one form to the other; --all stays unmeasured here because it acts on every background job in the operator's fleet, this session's own driving row included.
  • The per-runId lock covers both shapes. In the <id> case the newcomer finds a lock whose pid is dead, breaks it as stale and takes over — the lock file names the NEW owner, which is the correct outcome, not a leak. In the duplicate case the original is alive and the newcomer is refused: running the row's OWN exec command a second time (byte-for-byte what respawn re-runs, read off the live pty-host argv) exited 0 in ~1 s with autopilot: runId <id> is already driven by pid <pid> ..., wrote nothing to stderr, and left the lock naming the original. Whether the DAEMON leaves one or two rows is upstream's to fix; the board is protected either way.
  • Daemon-death survival is unmeasured. Whether an exec row survives the daemon dying (it does die a few times a day on this machine) was not tested, because a SIGTERM to the daemon did not kill it. Treat "row gone" as a normal exit: the stop record plus claude --resume <sessionId> is the recovery path, not the row.
  • The 5 s stop budget is not a wrap-up turn. A stop mid-turn loses that turn's result; the handoff written is the stop record, not an offload handoff.
  • One status line per LOOP turn. An operator turn completes inside the driver without passing through the loop's port wrapper, so it updates autopilot.jsonl but does not itself print a status line; the next loop turn does.
  • The private daemon files stay untouched. ~/.claude/jobs/*, daemon/roster.json, daemon/dispatch/* and control.sock are never written — only claude --bg, --exec, stop and agents --json are used.

Verify:

npm run selftest:autopilot-fleet-exec

Hermetic: a fake claude answers --version, agents --json, and --bg --exec <cmd> by really spawning the command with piped stdio and printing a COLOURED backgrounded · fake1234 line, plus a fake streaming child for the attached half. It covers the command builder (quoting, --attached, the forced --driver stream, the env prefix), the id parse through ANSI, the capability gate on 2.1.263 vs 2.1.200 vs a missing binary, a real exec whose stdin reaches the child, both fallback shapes (a client that exits silently, and one that never prints the line and is killed at the shortened wait), the status/waiting line formats (ANSI and non-BMP stripped, 100-char cap), and the attached runtime end to end: a stdin line becomes an origin: 'operator' record, a held line is delivered once a driver exists, and SIGTERM writes the resumable stop record synchronously. Positive control: the same assertions against a session nobody typed into find one loop record and NO operator record.

The kx75n half is measured on the REAL entry, not a fake: the smoke builds the exact command buildAttachedExecCommand hands the daemon (which is what claude respawn --all re-runs), executes it TWICE for one runId against a throwaway fixture repo, and asserts the second exits 0, names the holder pid on stdout, writes nothing to stderr, invokes no claude child, and leaves the first copy's lock intact — then that a clean SIGTERM of the first releases it. Positive controls: the lock unit cases flip from refused to acquired on the single change of the holder pid being dead, and removing the process.exit(0) from the refusal path turns the live half red.

Proven live on 2026-09-08 (claude 2.1.263, --model haiku, a throwaway checkout): the row appeared as {name: cyberine-ap-d13c4c3e, kind: background, state: working}, its detail moved from s1 t1 ctx 32% kwx2r · loop turn recorded to waiting: idle, waiting for an operator line (60s), please also run the lint typed into claude attach <id> landed as {turn: 2, origin: "operator", promptChars: 24, contextTokens: 64586} on the SAME session id, and claude stop <id> wrote the stop record and killed the process in ~0.7 s. The exec-ed environment carried neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY (presence tested, never printed).

Watch and steer a run from the fleet (cyberine run --bg / chat /run / the queue consumer, kb10z). Every launchBackgroundRun dispatch — cyberine run --bg, chat /run, cyberine enqueue + cyberine queue tick, a workflow's awaitRun step, research/teammate sub-dispatches — now tries the SAME claude --bg --exec fleet row kwx2r built for cyberine autopilot --bg, before falling back to the original detached npx tsx child. Operator decision 2026-09-08: this is the DEFAULT, not an opt-in — there is no --fleet flag, and nothing has to be set to get a row.

There IS one opt-OUT, added by knm4d: CYBERINE_FLEET_ROWS=0 (or false) makes every launchAsFleetRow caller skip the fleet attempt and take the detached path SILENTLY — the no claude fleet row (<reason>) note is not printed, because an opt-out is not a refusal. It exists for test runs: npm run sweep sets it for every smoke it spawns, and it chains, because the detached spawn inherits process.env, so a run's own child dispatch opts out too. Measured 2026-09-08 before the opt-out: one sweep left 2 dead cyberine-run-* rows in the operator's fleet (one from selftest:dispatch-guards' real background dispatch, one from that run's child dispatch through cyberine-bg-run.ts), and three sweeps had left 12. Do NOT set it for real work — a dispatch that opts out is unsteerable from claude agents.

cyberine run --bg "add a health check to the web server"
# Launched as the claude fleet row cyberine-run-4a564c13 (6f91916b); log: .local/runs/<runId>/console.log
# runId:  4a564c13-d92e-4de7-a9f8-4565eec913eb
# state:  launching (background)
# watch:  cyberine agents
# apply:  cyberine apply 4a564c13   (lands the winning diff once the run is done)

claude agents --json --all            # the row is named cyberine-run-<runId8>, kind: background
claude attach 6f91916b                # type a line -> recorded, read-only, in operator-input.jsonl
claude stop 6f91916b                  # SIGTERM; claude rm is the operator's own call, cyberine never rm's a row

What actually happens: the shared helper launchAsFleetRow (src/autopilot/fleetExec.ts) reuses kwx2r's probeExecCapability / launchFleetRow / parseBackgroundedId unchanged, and adds one naming rule (runFleetRowName, prefix cyberine-run- instead of autopilot's cyberine-ap-, same digit-tail guard) and one env-size rule launchBg.ts needs that autopilot's --attached re-invocation never did: CYBERINE_TASK here carries the FULL task plus memory/rules/invariants preambles, which can be many KB — too large to embed safely as a literal shell-assignment prefix in the --exec string, and (measured by kwx2r, 2026-09-08) the daemon does not forward this call's own spawn environment to the job it execs. So the launcher writes the whole per-run env dict to .local/runs/<runId>/bg-env.json (mode 0600) and hands the exec'd command only one small pointer, CYBERINE_BG_ENV_FILE=<path>; cyberine-bg-run.ts loads that file into process.env before reading anything else, and this indirection is invisible to a caller — BgLaunch just gains dispatchMode: 'fleet' | 'detached' plus fleetRowId? / fleetRowName? when it went to the fleet.

Row outcome line (krjsv). The daemon shows the LAST STDOUT LINE as a fleet row's detail (established by kwx2r for the autopilot fleet row), so cyberine-bg-run.ts prints exactly one outcome line to stdout right before it exits, on every exit path (a pre-execution failure and a normal end alike), and nothing else after it — a run that used to leave the row reading Failed - exit 1 - or Done - with an empty summary now shows the real reason. The line is built by the pure buildRunOutcomeLine (src/store/runOutcomeLine.ts) from run.json: <runId8> <state>: <detail>, where <detail> is run.detail on error, the last step's judge verdict (<winnerAgentId> — <rationale>, or the step's own detail when there is no verdict) on done, and the literal word cancelled for a cancelled run. Passed through redactSecrets and capped at 160 chars. Pinned by npm run selftest:run-outcome-line against fixture run.json objects for all three states.

Known limitation: the two exit paths that print it are the pre-execution-failure branch and the normal end — the very first guard (CYBERINE_TASK empty) exits before any run.json exists, so it still prints only to stderr with no row summary.

The run's own PTY becomes an operator channel exactly as it does for autopilot: cyberine-bg-run.ts installs installOperatorInputReader() (src/store/operatorInputReader.ts, the SAME createInterface({ input: process.stdin }) pattern attachedMode.ts uses) right after computing its runId, and every non-empty trimmed stdin line is appended verbatim as { at, text } JSON to .local/runs/<runId>/operator-input.jsonl through RunStore.appendOperatorInput. This is a read-only recording channel — kb10z does not feed the line to anything (no inject(), no planner, no judge); a later task can wire that up. It shows up read-only in two places:

  • TUI (cyberine agents / npm run tui): the run detail pane (RunDetail.tsx) grows an "operator input" panel, last 5 lines with their timestamps, only when the file is non-empty — polled the same way useRunTokenStream.ts tails events.jsonl (useOperatorInputTail.ts, byte offset, 1 s interval).
  • Web dashboard (cyberine web): GET /api/runs/:id/operator-input (and its /v1 alias) returns { records: [{ at, text }] }; the run detail page polls it every 3 s into a collapsible "Operator input (read-only)" panel next to the diff panel, hidden when empty.

Fallback (announced on stderr, one line, never thrown): [cyberine] run <runId8>: no claude fleet row (<reason>); falling back to a detached background process. Same refusal set as autopilot's --bg: claude missing or older than the --exec known-good version, an unparseable --version, or no backgrounded · line within 20 s (the attempt is killed first). The fallback path is the exact pre-kb10z spawn('npx', ['tsx', ...], { detached: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, ...extraEnv } }) call — extraEnv is the same object literal that used to be inlined directly into that env: field, only extracted to a named variable so both dispatch shapes can read it; nothing in the fallback branch's argv, stdio, or env construction changed.

Verify:

npm run selftest:fleet-exec-rows

Hermetic and independent of the kwx2r autopilot-fleet-exec-smoke.ts fixtures (its own fake claude binary, its own tmp dir), though both smokes exercise the same fleetExec.ts primitives. Covers: runFleetRowName's digit-tail guard; a supported claude really --exec-ing the REAL worker script (scripts/fleet-exec-rows-worker.ts, which installs the SAME production installOperatorInputReader cyberine-bg-run.ts does — not a re-implementation) with a fed stdin line landing in operator-input.jsonl; a positive control where nothing is typed and the file stays absent or empty; and both fallback shapes (an old version refused before any spawn, a client that never prints backgrounded refused within a shortened wait, not the 20 s production default).

Proven live on 2026-09-08 (claude 2.1.263, real cyberine run --bg dispatch from this checkout): the row appeared as {name: cyberine-run-4a564c13, kind: background, state: working}; claude attach 6f91916b in a throwaway tmux session, typing please also run the lint, landed in operator-input.jsonl as {"at":1788812670938,"text":"please also run the lint"} within about a second (well under the 3 s the reader's own file-append needs once a line reaches its stdin); claude stop / claude rm and the tmux session were cleaned up immediately after, leaving no cyberine-run-* rows. The run.json before/after byte-identical check for the fallback path was attempted live and abandoned: this development machine carries already-authenticated real credentials in the ambient shell environment (independent of this repo's own .env), so a build-fleet pinned to a deliberately-invalid worker id still "degrades to all available adapters" and dispatches a real paid call rather than failing fast and network-free — two such attempts were killed as soon as this was noticed. The claim instead rests on static inspection: extraEnv in src/launchBg.ts is the unmodified object literal the pre-kb10z code inlined directly into the detached spawn's env: field, and the fallback spawn(...) call's argv/stdio/env construction is untouched — git diff against the merge-base shows only the extraction, never a value change.

Stopping a run (kye4c). A SIGTERM to the background process is a CANCELLATION, not a crash: src/cyberine-bg-run.ts flushes the run record as state: cancelled with a cancelled by SIGTERM (operator stop) after <n>s detail, then exits 0 — inside a 3 s budget, because claude stop SIGKILLs ~5 s later. A run that genuinely fails is untouched and still exits 1, so the exit code now separates the two. Measured live on claude 2.1.263, same procedure twice with only the exit code changed:

what happenedprocess exitrun.json stateclaude agents row
SIGTERM to the exec'd process, kye4c fix0cancelleddone
SIGTERM to the exec'd process, before kye4c143cancelledfailed
claude stop <id>eithercancelledstopped

The third row corrects this task's own premise. claude stop lands the row in stopped regardless of the exit code — the A/B above produced stopped both ways — so the Failed · exit 143 the operator saw comes from the OTHER way an exec row dies: a SIGTERM the daemon did not initiate (a parent killing the process tree, the sweep tearing down), where it sees only a non-zero exit. That is the case the fix converts to done.

Two limits worth knowing. SIGINT is not covered in practice: the handler treats Ctrl-C the same way and keeps exit 130 (the shell convention), but under the tsx loader the process is torn down before the async flush lands — measured by forcing that branch to return 131 and still observing 130 — so a Ctrl-C'd run keeps whatever state it had. claude stop sends SIGTERM, so this does not affect the fleet path. SIGKILL remains uncatchable: a run killed past the 5 s budget leaves its record running until the reaper picks it up.

Known Limitations and Deferred Work (fleet-row run --bg, kb10z)

  • No Needs input state. Same as autopilot's --bg: the daemon derives working/idle from PTY output activity, so a quiet run just goes idle, never a distinguishable "waiting for you" state.
  • --exec stays undocumented in claude --help on 2.1.263 — registered in CLI_FLAG_COMPAT with a known-good version so an older binary is refused before the spawn, but upstream could rename it.
  • Daemon-death survival is unmeasured, for the same reason kwx2r left it unmeasured: it acts on the operator's whole fleet, not one row, so it was never exercised in isolation for this task either.
  • Operator lines are recorded but not acted on. operator-input.jsonl is a read-only channel; no planner, judge, or session driver reads it back. Feeding it into the run is explicitly out of scope for kb10z (a later task).
  • logPath is only populated in detached mode. BgLaunch.logPath still names .local/runs/<runId>/console.log, but a fleet-row dispatch never opens or writes that file — its stdout/stderr go to the row's own PTY instead, exactly like autopilot's exec rows.