Task workflow inside cyberine chat — the project task manifest
/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 cyberine tasks and cyberine next read.
The chat, the CLI and a Claude Code session read and write the same backlog without clobbering each other:
each cyberine status flip is its own small file under .local/tasks/status.d/, never a rewrite 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 from the former nf-tasks skill's implementation — 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. A task whose title starts with Owner: decide also needs default and undoCost (one line each, the same values as cyberine tasks add --default and --undo-cost); without both the writer refuses the call and nothing is written. 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.mdEvery 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 it carries a blocker: line (external hold — clear the line to release); waiting when at least one dep is not yet [done]. [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 (N blocked by an external blocker (release one with ...) and/or N waiting on deps (finish them or mark them done) /
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 verificationEffective status = the manifest's base token OVERRIDDEN by the last overlay line for that id (a status.d/ file or a legacy status.log line)
(see "Status overlay" below) — so what /tasks shows is the same view cyberine 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.
(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 k7m2q or /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/runis allowed to dispatch). See the permission-mode table above./xmode— the execution mode documented here. It controls WHICH fleet topology/runand/dispatchuse, not WHETHER they are allowed to dispatch.The chat's
/dispatch(and/run, the plannerdispatch_runtool,/backlog run,/workflow, Ctrl+B) gates on/xmodeindependently of/mode, so a permission-mode ofacceptEditsplus an execution-mode ofresearchis valid and/dispatchstill refuses it.
/xmode:
/xmode(no arg) — cycle forward throughcompete -> 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 (
compete, in the accent colour for every mode) and persisted on the session record (ChatSessionRecord.executionMode), socyberine chat --continue/--resume <id>restores it.
compete (the default), deep, smart, and rush dispatch through the fleet launcher (one task -> N agents -> judge picks the winner), differing only in fleet size / model tier / judge chain (T324). swarm goes through the same launcher with CYBERINE_MODE=swarm, which splits the task into parallel chunks (src/engine/swarmTaskWorkflow.ts); the chat used to refuse it as "not built yet", a note that outlived the swarm workflow (kj5m6). Every chat dispatch path now passes the selected mode, the two Ctrl+B paths included. research dispatches the standing research workflow (plan -> search fan-out -> verify claims -> synthesize report) from /run, the planner's dispatch_run and dispatch_tasks (one research run per task) and both Ctrl+B paths (khh1q, kj5m6):
/xmode research
/run "what are the trade-offs between SQLite and DuckDB for embedded analytics?"
/run --prior-art competitors of the Widget tool
/run --prior-art --ours "offline mode, partial REST API, no SSO" competitors of the Widget tool
/run --prior-art --ours @.local/research/ours-widget.txt competitors of the Widget tool--prior-art and --ours <text|@file> (k30t5) must come before the question and mean what they
mean on cyberine research: the run writes .local/research/prior-art-<slug>.md with the parity
matrix and the recommendations ledger, and the dispatch line names the
cyberine research recs <report> --check follow-up. A quoted --ours value keeps its spaces;
@file is read relative to the chat's working directory. --ours without --prior-art, or flags
with no question, is refused before anything starts.
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). The paths that dispatch build tasks rather than questions (/dispatch, /backlog run, /workflow) refuse research with a one-liner rather than silently downgrading to compete:
fleet: research — this path dispatches build tasks, not research questions (use /xmode to switch: compete | swarm | 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, /backlog run and
/workflow remain unwired for research; /run, the planner's dispatch_run and dispatch_tasks, and Ctrl+B launch it.
/settings — the visual settings screen (T418)
/settings # open it; Esc closesOpens 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 knobsKnown 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_HOMEmost 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,KeyHintFooterand the settings screen shipped.SearchableListwas NOT extracted — its search box and windowing live insideSettingsScreenfor now — and the row editors are rendered inline rather than as separateToggleEditor/EnumEditor/ValueEditorcomponents. That was deliberate: input ownership stays in ONEuseInputinSettingsScreen, because two Ink components both callinguseInputrace 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 modelandOpenCode modelaccept any string; they are not checked against the model catalog the way/modelis. - 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:
| Key | Values | Persists |
|---|---|---|
fleet | compete | swarm | research | deep | smart | rush | session record (executionMode) |
permission | default | acceptEdits | plan | auto | bypassPermissions | dontAsk | session record (permissionMode) |
model | any model id (e.g. deepseek/deepseek-v4-pro) | CYBERINE_CHAT_MODEL env key |
thinking | on | off | true | false | in-session only |
Examples:
/config fleet deep
/config permission acceptEdits
/config model openai/gpt-4o
/config thinking offEach 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):
dispatch error: cyberine tasks run: refused; nothing claimed
T00: not found
T01: [done]
T02: [doing]
T04: [backlog]
T09: [forbidden]
T10: duplicate-of T05
T11: held by blocker: <text>
T12: blocked on: T03, T07
T13: no do: and no title to dispatch — edit the manifestdo: 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. compete, swarm, deep, smart and rush are wired; in research the whole
command refuses with the fleet: research — this path dispatches build tasks, not research questions 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 failureIf 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":
It prints the same dispatched N task(s): report as above.
plan and dontAsk permission modes additionally block /dispatch with
plan mode: /dispatch blocked — dispatch is disabled in plan mode (use /mode to switch) /
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.
Status overlay — how a status flip is recorded
The cyberine chat, cyberine tasks status and every Claude Code session 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.d/<epoch-ms>-<pid>-<seq>.logStatus flips are NEW FILES — one line per flip (a legacy .local/tasks/status.log is still folded), TSV (the format the former nf-tasks skill's
nf_append script also wrote, so its boards still fold):
<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).
Each flip is its own file, written under a temp name and renamed into place, so concurrent writers
never share a file — no lock required. Every writer resolves 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. Effective status =
base token in the manifest, OVERRIDDEN by the last overlay line for that id (a status.d/ file or a legacy status.log line):
- The manifest file is NEVER rewritten by a flip — it stays as the author wrote it.
readManifestreads the manifest then folds the overlay onto every task's status before returning.- Every status writer (
setStatus,cyberine tasks status) writes lines in the same TSV format, so every surface shares one backlog.
What this gives you in practice:
/dispatchflips a task to[doing]and the same id appears as[doing]incyberine tasks status(the fold is the same).- Closing a task works from either surface:
cyberine tasks status done Tnnand the chat's/dispatchcompletion handler both append adoneline; whichever lands last wins. - Concurrent agents — chat + Claude Code + an external
cyberine tasks run— never clobber the manifest, because every flip is a separate overlay file.
setStatus() honours CYBERINE_TASKS_AGENT (legacy NF_TASKS_AGENT is still read) for the agent
tag (whitespace sanitised; falls back to user@pid otherwise) so the provenance column of each flip line (status.d/*.log) shows which writer wrote it —
useful when you scan the overlay by hand to see who claimed what. Writable statuses are
todo | doing | done | backlog | forbidden (dispatched applies to recurring R-ids only; 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 reportsn,median,p90,medianCostUsd; belown = 3samples the last three arenull(printedn/a— too few points for a percentile to mean anything). An outlier is a task whose summed actual sits above the NEXT class'sp90or below the PREVIOUS class'sp10(the check is skipped when that neighbour class has zero samples);inferrednames 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-calibrationTwo 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 former nf-tasks skill (2026-09-06) into src/tasks/board*.ts,
so cyberine alone can create, re-judge, reconcile and archive the board, and it still reads boards
that skill wrote. The format contract 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 the former nf-tasks
# skill's `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
# the brief inline, or piped on stdin, instead of a file (k1hrz); give one of the two, not both
cyberine tasks add --title "Add rate limiter" --do "..." --done-when "..." --effort M \
--brief $'## Intent\nBound login abuse per IP.'
printf '## Intent\nBound login abuse per IP.\n' | cyberine tasks add --title "Add rate limiter" --do "..." --done-when "..." --effort M --brief-file -
# files the writing session already found relevant (k4mw5): --read-first is repeatable, one
# `path` or `path:symbol` per occurrence, at most 10 (an 11th exits non-zero and writes nothing).
# The list is stored as a `read-first:` line (comma-joined) and printed first in the worker prompt
# of `cyberine autopilot lanes <id>` and `cyberine next --dispatch <id>`, as a block that starts
# with `Read first:`. `tasks save` takes the same list as `readFirst` in a save item
# (`cyberine tasks save --schema`); `tasks set <id> --read-first "a.ts, b.ts:fn"` replaces it and
# `--read-first -` removes it. A task without the list gets exactly the prompt it got before.
# Known limitations: the list is a starting point for the worker to verify against the tree, never
# a limit on what it may read, and it goes stale when the files move (nothing re-checks it); an
# entry cannot contain a comma; nothing fills it from a session automatically, so it exists only
# when the author passes --read-first or a save item carries readFirst; plain `cyberine run "<text>"`
# has no board task, so it never gets the block.
cyberine tasks add --title "Fix the retry loop" --do "..." --done-when "..." --effort S \
--read-first src/core/retry.ts --read-first src/adapters/LoopAgentAdapter.ts:runLoop
# 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 only a board field 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
# scope an existing task to a sub-directory or submodule (path: is what a session scoped to that
# directory filters on, so a task without it is hidden there). One repo-relative path; "./" and a
# trailing "/" are trimmed; an absolute path, ".." or whitespace is refused; "-" removes it.
# Known limitation: no batch form -- one id per call, and set --batch does not carry path.
# --path ALONE is also allowed on a live task that is effectively done (kgyqz: attribution, not
# re-tiering); --path together with any other field on a done task, a [forbidden] task and an
# archived task all still refuse.
cyberine tasks set kvg --path kite
cyberine tasks set kvg --path -
# re-tier MANY tasks at once (ported from the former nf-tasks skill): 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], add the "the [status] bracket can lag the overlay" header note when an older board
# lacks it (the same line tidy writes, once), 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, and an effort
# M/L task with no brief. It is also the one command that lists every
# unknown-field manifest line in full: every other board command prints a single summary line
# ("cyberine tasks: N manifest lines carry unknown fields (first: line L field:); list them with
# `cyberine tasks doctor`", kb4de). Other lint warnings still print per line; --json output is unchanged.
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"
# edit what has no other verb (k2zwd): a task's area, one brief section, a malformed status bracket
cyberine tasks set k7m2q --area web,auth # replace the area tags (comma or space separated)
cyberine tasks set k7m2q --area - # clear the area line
cyberine tasks brief set k7m2q gotchas --text "None - nothing tricky on the read path" --reason "re-checked"
cyberine tasks brief set k7m2q pointers --file pointers.md # or --file - to read stdin
# -> k7m2q: brief pointers replaced
# -> undo: cyberine tasks brief set k7m2q pointers --text '<the previous section text>'
cyberine tasks repair --dry-run # list every header with an empty, unknown or missing bracket
cyberine tasks repair # rewrite those brackets to [todo], one line per header
cyberine tasks repair --status backlog --json # another status; the plan and result as one JSON object
# -> repaired: line 42 k7m2q [] -> [todo]
# -> undo: the original header lines are in the journal (cyberine tasks timeline --grep "original line"); ...
# record where a claim came from (kh7q7): a message from another Claude session is checked
# against the repo or the screen BEFORE it is written down, and the line says how. A peer source
# REQUIRES exactly one of --verified "<how>" or --unverified (stored as `verified: no`); with
# neither, or with both, the command exits 2 and writes nothing. --source also accepts user and agent.
cyberine tasks note k7m2q "peer says the retry cap is 3" \
--source peer:dot-claude_312 --verified "read src/retry.ts:41, MAX_RETRIES = 3"
# -> update 2026-10-26: peer says the retry cap is 3 [source: peer:dot-claude_312; verified: read src/retry.ts:41, MAX_RETRIES = 3]
cyberine tasks note k7m2q "peer says the cap moved to 5" --source peer:dot-claude_312 --unverified
# -> ... [source: peer:dot-claude_312; verified: no]
cyberine tasks add --title "Raise the retry cap" --do "..." --done-when "..." --effort S \
--source peer:dot-claude_312 --verified "ran grep MAX_RETRIES, one definition"
# the task block gets `source: peer:dot-claude_312` and `verified: ran grep ...` lines; `save` takes
# the same two keys per task ("source", "verified"; "verified": "no" means unverified)
# set and brief note take the same flags (k2xq8); set --source - removes both lines, and the text
# status view ends a sourced task's line with [source: <source>; verified: <how>|no]
cyberine tasks set k7m2q --source peer:dot-claude_312 --unverified
cyberine tasks set k7m2q --verified "re-read src/retry.ts:41 after the merge"
cyberine tasks brief note k7m2q "ops says the cap stays" --source peer:dot-claude_312 --unverified
cyberine tasks status --json | jq '.tasks[] | {id, source, verified}' # both null when absent
cyberine tasks doctor # section 13 lists peer-sourced tasks and notes whose verified: is no or missing
# 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"
# decision ledger (ktwy2): a decision an agent took alone is one `decided` journal event,
# written the moment it is taken, never an edit of the brief. decide prints the event's ref
# (<ts>@<by>). --revert writes a NEW `reverted` event pointing at the ref (the earlier line is
# never rewritten); --ack marks auto decisions reviewed: one or more refs in one all-or-nothing
# write, or --all. The session that reports its decisions to the operator acks exactly the refs it
# reported, in the same turn (the report is the review; --all would also cover a decision another
# session recorded after the list was read). Unreviewed auto decisions show in
# `tasks status` (and the banner), `tasks status --json` (.unreviewedDecisions), `tasks end`
# and the handoff seed of `tasks end --handoff`, until an ack covers them.
cyberine tasks decide k7m2q --choice "reuse the existing retry helper" --rejected "a new backoff module" --reason "same pattern, one place to tune"
cyberine tasks decide k7m2q --choice "..." --rejected "..." --reason "..." --status owner --by claude-opus-5-5
cyberine tasks decide --run policy-x --choice "..." --rejected "..." --reason "..." # a decision with no task: stored as id run:policy-x, listed and acked like any other, nothing added to the manifest
cyberine tasks decide --revert "2026-10-14T09:35:43.774+07:00@niafam@20833" --reason "owner prefers the module"
cyberine tasks decide --ack "2026-10-14T09:35:43.774+07:00@niafam@20833" "2026-10-14T09:36:10.002+07:00@niafam@20833"
cyberine tasks decide --ack --all # every auto decision at or before now
cyberine tasks status --json | jq '.unreviewedDecisions'
# 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, add the
# "the [status] bracket can lag the overlay" header note when an older board lacks it, 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
# one hygiene pass over an existing board, before planning on top of it (kb78y). The default
# run is a read-only preview (it never takes the lock and writes nothing) of: stale status
# headers (manifest bracket behind the overlay), dead dep ids (with the repoint it would make),
# duplicate candidates (exact, and near ones for you to judge), and decisions buried in a
# non-manual task's blocker:, title or `update` line
cyberine tasks hygiene
cyberine tasks hygiene --json # same findings, schema board-hygiene/1
# after one yes on the preview: tidy, the unambiguous dep repoints, the exact-duplicate merges
cyberine tasks hygiene --apply
# what remains is judgement, each through its own command: a dead id with no known replacement
cyberine tasks deps set k7m2q "k9s4m"
# a near duplicate: merge it, or record that the pair was reviewed and is NOT a duplicate
cyberine tasks merge k8aa1 k8bb2
cyberine tasks merge --reject k8aa1 k8bb2
# a buried decision becomes its own manual owner task that the dependent lists in deps
cyberine tasks add --title "Owner: decide the per-seat price" --do "Choose the price." --done-when "The price is noted." --effort S --exec manual \
--default "The pricing page keeps its placeholder." --undo-cost "None before launch; one config edit after."
cyberine tasks deps set k2t25 "k1s1d"
cyberine tasks status unblock k2t25
cyberine tasks tidy # new tasks land after the tombstones; regroup
cyberine tasks hygiene # expect: hygiene: nothing to fix.
cyberine tasks doctor # expect exit 0
# 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
# stale header notes (km1vb): list every single-line <!-- ... --> comment outside task blocks
# with its line number; `stale-ids` marks a line whose task ids are all off the live board
# (a hint only), `managed` marks a header line the board tools maintain
cyberine tasks comments
cyberine tasks comments --json # schema board-comments/1
# drop the lines you judged stale: preview, then the same without --dry (under the board lock)
cyberine tasks comments drop 29 42 --dry
cyberine tasks comments drop 29 42
# 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.
# an exec:agent entry with no "verify" (a command that exits 0 when the task is done) still saves,
# but prints one "save: warning: <id>: exec agent task has no verify command" line (k6c0y);
# `cyberine draft` prints the same warning for every drafted task without one. Manual tasks are not warned.
# split owner work out of an existing task (ka388): an explicit board-split/1 decomposition
# (shape resource | decision | eyes | convert) written in one recoverable write. --dry prints the
# plan (new tasks as @ref, the source diff, every dependent's old and new deps) and writes nothing.
cyberine tasks split --schema
cyberine tasks split k1001 --input split.json --dry
cyberine tasks split k1001 --input split.json
# 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
# discard drafts you will not keep (kbww7): they move to .local/tasks/drafts/rejected/ and the
# journal gets one note per draft with the reason; --reason is required, and an unknown or
# already-rejected name moves nothing. Move a file back into drafts/ to restore it.
cyberine tasks inbox reject draft-add-rate-limiter-1792000000-1234.md --reason "covered by k7m2q"
cyberine tasks inbox reject --all --reason "premise was wrong"Known Limitations and Deferred Work (kh7q7, peer claims). The tool records a verification, it cannot check one: --verified "<how>" is free text the session wrote, and nothing tests it against the repo. doctor section 13 is advisory (it never changes the exit code) and lists only a task whose source: is peer: and a manifest update line ending in [source: peer:<session>; verified: no] or with no verified: part; a note written by hand without the suffix is invisible to it. --source user|agent is optional metadata with no gate. --verified or --unverified without --source is refused, and so is --verified no (use --unverified). Since k2xq8 set and brief note take these flags and the text status view shows both fields (--verified alone on set is accepted only when the task already has a source:). A vendored TaskBoard bundle built before this change treats both fields as unknown (a lint warning, nothing lost). Upstream nf-tasks lints source: and verified: as unknown fields.
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; brief set is the verb that replaces a section. 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.
Known Limitations and Deferred Work (k2zwd, repair, set --area, brief set). repair fixes a header only when its bracket is empty, holds an unknown single word or several words led by an unknown one, or is missing; a bracket whose first word is a known status ([done 2026-08-10]) is left alone, and it rewrites the manifest only, never the archive. It sets every repaired header to one status (--status, default todo), because the lost status cannot be recovered; a repaired todo task becomes runnable at once, so use --status backlog when unsure. A recurring R header is repaired to [recurring]. The undo line names the journal, it is not a restore verb: put an old bracket back with cyberine tasks status. set --area accepts any whitespace-free token and refuses a closed or archived task like the other set fields. brief set replaces one section at a time, refuses a text line that starts with ## , refuses a task with no brief or one whose brief lacks that section (only outOfScope is created on demand, before ## Updates), and its undo line carries the whole previous text in one quoted command. It does not edit ## Updates entries, and natural-language routing to these verbs is not shipped yet.
Split owner work out of a task (ka388). cyberine tasks split <id> --input <file|-> [--dry] lands the decomposition the contract in docs/tasks-manifest.md section 15 defines: the source keeps its id (with an optional amend of title, do, done-when, exec, model, deps or blocker, and a required reason), the new Owner (exec: manual, model: -, title Owner: ...), verify and agent tasks are minted and briefed, and every current dependent of the source must be mapped in dependents to keep (it needs only the code) or a ref such as @verify (it needs the live result); an unallocated dependent refuses the whole split. The shape is checked: resource needs one owner and one verify task that depends on @owner and @source, decision one Owner: decide task that amend.deps names, eyes one owner task that depends on @source, convert no tasks and amend.exec: manual. A doing, claimed (live lease:), forbidden, duplicate, done or archived source is refused, and so is a dep the split introduces on an archived id or any dependency cycle the result would close; a refusal writes nothing. The write is journalled in split-ops.jsonl beside status.log: op is the retry identity, so the same op and payload again is a no-op that prints the recorded ids, a changed payload under the same op is refused, and a write interrupted after the ledger's begun line resumes with the ids it recorded. Unlike save, split never skips a task because its title and do match an existing block. npm run selftest:owner-split-writer drives every case on the fixtures in scripts/fixtures/owner-split/.
Known Limitations and Deferred Work (split). The decomposition is always supplied by the caller: split makes no judgement about which part of a task needs the owner (the board skills and plugin/references/board-run.md do that), and the owner-mixed lint below only warns, it never splits. It splits one source per call. amend.blocker can only remove a blocker, not replace it. The new tasks are always written [todo], placed right after the source block.
Spot tasks that mix agent and owner work (k3wkr). cyberine tasks add, cyberine tasks save, cyberine tasks doctor (section 11) and cyberine tasks hygiene (section 6) print an advisory owner-mixed: line for an open task of one of two kinds: an exec: agent (or opencode) task whose blocker, title, do or done-when needs a credential, an account, an owner-created resource or an owner decision, and an exec: manual task whose title, do or done-when also holds agent-runnable work (a script or benchmark run, a build, code or tests to write, a config edit, an npm run command). Each line quotes the matched text and names the fix, cyberine tasks split <id> --input <file> --dry (cyberine tasks split --schema prints the input shape). A line never blocks anything: add and save still write the task and exit 0, and doctor keeps its exit code. doctor and hygiene print the same line for the same task, produced by src/tasks/ownerMix.ts, which shares its classifier with the buried-decision check (section 5 of hygiene). npm run selftest:owner-split-lint drives the cases.
Known Limitations and Deferred Work (owner-mixed lint). The lint is a keyword heuristic over short clauses, so it can miss an owner need phrased without the words it looks for and can flag a clause that only mentions one. It skips a clause that reads as code work (validate the API key format, credential parsing, domain model, token parser) or as config the owner fills later (env, config, fails clearly when missing). It reads deps only to stay quiet after a split (kjfwk, widened by kravj): an exec: agent finding is dropped when the task, directly or through other tasks it depends on, depends on an owner task, meaning a task whose title starts with Owner: or an exec: manual task with no agent-runnable work in it. The owner task can be open or done, on the board or in TASKS_ARCHIVE.md, and it does not have to hold the same kind of need as the finding. A dependency on a task that is not an owner task, or no dependency at all, leaves the warning, and a task blocked on a free-text blocker naming the owner with no owner dependency stays flagged; an exec: manual task that bundles agent work stays flagged whatever it depends on. The lint never decides which task should depend on which, and it does not check that the owner task really covers the need. A blocker that is really an owner action shows up both here and in the buried-decision section of hygiene. doctor and hygiene both read the archive for the owner-task check, and add and save do too. doctor reads the manifest header status and hygiene the overlay-effective status when choosing which tasks to list, so the two can differ on a board with pending status flips. Only the fix named in the line removes a warning; nothing is auto-split.
Set CYBERINE_TASKS_SYNC_DIR=.codex (legacy NF_TASKS_SYNC_DIR is still read) 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 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. 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 line (written by the former skill) 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. cyberine tasks hygiene detects with deterministic checks and fixes only what is deterministic: --apply runs tidy, repoints a dead dep id only when the dedupe ledger records the id as merged into an id that exists (or the id differs only in letter case), and merges only exact duplicates (the same title and do: once runs of whitespace are collapsed; case and punctuation count, so src/foo-bar.ts and src/foo_bar.ts are two tasks) into a survivor chosen by status, then brief, then dependents, then created: (two [doing] members, or a group holding a pair recorded by merge --reject, are reported and left for you). A task with a second dead dep that has no replacement is skipped whole, because deps set refuses unknown ids; a near duplicate (same title with a different do:, or 0.8 word overlap in the title) is only reported, and a pair already recorded by merge --reject is not raised again. Buried-decision detection is a keyword heuristic over blocker:, the title and update lines of tasks that are not exec: manual, classed decide, provision or operator-action; it has false positives (a blocker that is a real outside wait) and misses decisions phrased without those words, it is never fixed by --apply because minting the Owner: task needs the decision's wording, and a task that already lists an Owner: task in its deps is skipped. It does not remove [forbidden] tombstones (it groups them), does not rewrite bare note:/ledger: lines, and the near-duplicate scan is skipped above 1500 live tasks. 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). cyberine tasks comments drop removes whole single-line <!-- ... --> comments outside task blocks and code fences, by line number, and nothing else: it cannot rewrite a comment, drop a multi-line <!-- ... --> span, a comment inside a task block, or the header lines the board tools maintain (the status-token line, the overlay-lag note, the tombstone marker); it never prunes on its own, because stale-ids cannot tell a stale note from live prose, so the line numbers come from your reading of cyberine tasks comments. When a dropped run leaves two blank lines side by side it keeps one.
Flags, per subcommand:
| Subcommand | Flags |
|---|---|
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 |
recurring | list 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 |
add | required --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 <text> or --brief-file <path|-> (- reads stdin) |
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) |
deps | show <id>, set <id> "<ids|->" |
archive | --auto |
end | --force |
reconcile | --dry-run (alias --dry) |
doctor | none |
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) |
suggest | none |
tidy | none |
hygiene | --apply (run the mechanical fixes; without it the run is a read-only preview), --json |
move <id> | --before <anchor-id> or --after <anchor-id> (exactly one required) |
save | --schema, --input <file|-> |
draft | optional --to <repo-path>, --do <text>, --done-when <text>, --why <text>, --by <model-id>, --file <path> or --stdin; required title |
inbox | none |
Header brackets lag the overlay. A status flip (status, promote, the automatic promotion pass) appends to the overlay and never rewrites the manifest, so a task's ## <id> [status] bracket stays behind until the next reconcile (tidy, hygiene --apply, end, archive). The effective status is header plus overlay: read it with cyberine tasks status --json or cyberine next, never by grepping the headers. A board seeded since 2026-10-05 says so in its header comment, tidy adds the same line to an older board, and the banner counts the gap as N status flips not yet in the headers (it read overlay pending before).
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; CYBERINE_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:
runreads the effective board under the shared lock, orders runnable[todo]tasks byp0,p1,p2and then manifest order, and writes[doing]flips to the overlay before releasing the lock. Missing priority meansp1. An id selection is all-or-nothing. Its plan includes the task's model tier, exec kind, brief path when present,[cyberine-task <id>], andCYBERINE_TASKS_ROOT=<root>so a worker resolves the same board.NF_TASKS_ROOTremains a supported fallback pin.--dryreads 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 recurringR-block: one currently DUE or NEW that carriesexec:gets a trailing "## Recurring dispatch" section in the plan and, on a real (non---dry) run, an overlay line<id> dispatched-- a distinct token fromtodo/doing/done/backlog/forbiddenthat can never collide with a task flip and thatreconcilecan never bake into the manifest (its header-match regex only ever seesT/k-shape ids). A block already markeddispatchedis skipped untilstatus did <id>resets its cadence, so one overdue chore is dispatched exactly once per due cycle even across concurrentruninvocations (same claim lock as the task side). Amodel:missing or invalid on anexec: agentblock defaults tosonnetwith aNOTE:line, same as the task side.cyberine nextshows the same priority order;cyberine next --dispatch <id>immediately launches one run and spends the configured provider or seat quota.cyberine tasks runonly claims and prints a plan for sessions to execute.cyberine autopilotis the separate unattended board loop that selects, runs, and closes tasks. TUI/dispatchclaims through the same locked path astasks runbefore it launches.add/save/set/deps/merge/drop/forget/archive/migrate/reconciletake 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. Astatusflip takes NO lock — it is an append, which is the whole point of the overlay.archivewritesTASKS_ARCHIVE.mdFIRST, then the manifest, both atomically. A crash between the two leaves the task in BOTH files; recovery is always to delete the block fromTASKS_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:reconcilerewrites its bracket only when an explicittodoflip appears somewhere in the pass. - A block whose bracket is empty (
## k1 []) has no status to rewrite, soreconcileleaves the whole block byte-identical (nocompleted:, noevidence:, 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:
-
runprints 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--dryto reserve the current selection.--dry-with-mateand OpenCode-specific routing for anexec: opencodeblock are deferred;runprints the same plain prompt/CYBERINE_TASKS_ROOTshape for every exec kind, task or recurring. -
A claim carries a lease (ky0c9). Each
runstamps alease: <session>@<host> <expires-ms>field onto the claimed block and flips it to[doing]. The claim outlives neither its session nor the board:runreclaims any[doing]task whose lease expired (back to[todo], one journal line naming the dead owner) before selecting, andreconcileclears alease:line the moment a task leaves[doing]. Renewal is the explicitrun --renewheartbeat (any board write does NOT renew — a session doing long work with no intermediate write must call it). Astatus done/doingflip on a task you no longer hold is refused with the holder's name, never written over. -
No LLM judgement anywhere in this surface.
saveis a validator and writer only: it consumes an already-decided JSON batch (--schemaprints the shape) and makes no dedupe, triage or brief-writing judgement of its own.mergelikewise records a verdict you already reached; it does no similarity scoring. -
Drafts remain pending until named in a batch.
inboxprints thefrom,draft, and optionalpathvalues to copy into an already-decidedsavebatch. It does not decide whether to turn a draft into a task. An omitteddraftkey leaves that file pending; a misspelled key rejects the whole batch before writing. -
Recurring writers and dispatch.
recurring add/set/dropwrite R-blocks inR_FIELD_ORDER(every, last-done, exec, model, reasoning, do), matching the validation the former nf-tasks skill applied (--reasoningonly withexec: agent;model:/reasoning:only withexec:set;droptakes an exact id, never a prefix).rundispatches an overdueexec:-bearing block once per due cycle (see therunbullet above). -
Task-quality warnings never refuse (k5n66, narrowed by k1hrz).
addprintswarning: <id>: ...on stderr only for an effort M/L task written without a brief (--briefor--brief-file); the task is still written and the exit code is unchanged.doctor's section 10 reports that for every open task, plus ado:that reads as more than one sentence (terminators counted outside backticks, withe.g.-style abbreviations and version numbers ignored). There is no length limit ondo:: the 300-character warning was removed on 2026-10-17 (operator D1-A) because 832 of 1858 boarddo:lines exceeded it and nothing truncatesdo:for display. -
A forbidden flip writes
reason:only with--reason.status forbidden <id> --reason "<why>"reconciles at once and writesreason: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.
-
addcannot setpath:(onlysavecan), andaddwrites a brief file only when--briefor--brief-fileis given — a missing brief is reported bydoctoras informational, never fatal. The brief is copied VERBATIM totasks/<id>.md(--briefgets a trailing newline when it lacks one); it is not folded into the six-section templatesaverenders, because inventing placeholder sections would put prose on disk nobody wrote. A brief with no## Decisions/## Rejected approachesheading simply yields no journaldecidedevent at reconcile time. -
archivedoes not stampcompleted:on a normally-archived block; onlylog's born-done blocks carry it, matching the skill. -
endis 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 — unlikearchive --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 adoingtask 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 --forcewraps anyway and leaves the drafts for a later converge. Re-running on a clean board is idempotent — it reportsno [done] tasks to archive.and reprints the unchanged board. -
dropandforgetare the two removal verbs;moveonly reorders a block. Dropping a live task deletes its manifest block and itstasks/<id>.mdbrief and appends ONEdropped <id>: <title>journal note (--reasonbecomes its body); earlier journal history,TASKS_ARCHIVE.mdandTASKS_DEDUPE_LEDGER.jsonlare 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 indeps:, and one a surviving task isduplicate-of. Ids are EXACT: a bare prefix is refused with a did-you-mean, unlikestatus,set,depsandmerge, which accept an unambiguous prefix.--dryprints the plan and writes nothing. -
forgetis the only way to remove an archived,[done]or[forbidden]id, and it leaves no trace at all. It erases the manifest block AND theTASKS_ARCHIVE.mdblock, both brief copies (tasks/<id>.mdandtasks/archive/<id>.md), every journal event carrying the id (rewriting the day's.jsonlfile, or deleting it when the id was the only event left), and everyTASKS_DEDUPE_LEDGER.jsonlrow naming the id — unlikedrop, 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 sharesdrop's dependency/duplicate-ofguard against a surviving task. Ids are EXACT, same asdrop. It refuses an id with literally no trace on the board (no trace of this id) rather than silently doing nothing.--dryprints the plan and writes nothing. -
An unknown or empty bracket stays invisible.
[dne]or[]is parsed, warned about bycyberine nextand reported bycyberine tasks doctor, but stays out ofnext,/tasksand autopilot until it is hand-fixed;[done 2026-08-10]reads as its first token. The former nf-tasks skill refused both the same way from 2026-09-20, so both derived 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 freshstatus.dfile — original epoch, agent tag and extras intact — printsreconcile 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 doctorsection 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. The former nf-tasks skill discarded such a flip (measured 2026-09-21 on the same fixture,nf-tasks: reconcile discarded a done flip), so a board reconciled by it lost a flip that Cyberine would have kept; the holding guarantee is Cyberine's alone. -
setjournals onenoteper call that changed anything.--title,--why,--do/--done-when(the latter two carrying their required--reason) and, since kg2gf (2026-09-26, matching the former nf-tasks skill),--exec/--model/--effort/--reasoning/--priorityappend a singlenotewhose title lists the changed fields and whose body carriesold:/new:per field. An absentpriority:is journalled asp1, 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-whenare scope cuts and REQUIRE--reason.--done-whenalso rewrites the brief's## Done-whensection 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-whenheading.--doNEVER 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 thewhy:line. -
Re-applying an old
savedecision after a--title/--doedit can mint a duplicate.convergeApply's skip-check is thetitle+dopair, so oncesetchanges either half the original decision no longer matches the block and a fresh id is minted instead of being skipped. -
set --batch <json-file|-> --by <model-id>re-tiers many tasks in one write. It is all-or-nothing, takes only model, reasoning, exec, priority and effort per entry, and writes one journal event. -
No test framework here either.
npm run selftest:tasks-writeandnpm run selftest:tasks-lockare the regression guards; both use throwaway repositories under the OS tmpdir and never touch this repo's own.local/tasks/board.
Interop with the former 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. A second part (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 the former skill wrote (overlay, archive, journal, draft, briefs) --
and asserts cyberine reads and reproduces it, so a board that skill left behind on any machine
still reads correctly. The live half that drove nf-tasks through bun and the
TASKS_INTEROP_CAPTURE=1 recapture mode were removed with the skill on 2026-10-07. Known
limitation: the frozen board is one capture of nf-tasks at the sha recorded in
nf-frozen/meta.json and can no longer be recaptured.
Intent to inbox drafts — cyberine draft (k0gq0)
cyberine draft is the engine half of the A-Z flow: it turns one sentence of intent into reviewed task drafts without touching the board.
cyberine draft "any Claude Code account drives a paired Chrome profile"
cyberine draft "billing export for admins" --standard saas --rounds 2 --max-tasks 6
cyberine draft "..." --prior-art .local/research/<report>.md # inline a prior-art report
cyberine draft "..." --dry-run # print brief + tasks, write nothing
cyberine tasks inbox # review what it wrote- It derives a brief from the intent and the repository (top-level entries,
package.json, README head, recent commits), each line taggedstated,repoorassumption, then runs up to--roundsrounds of propose -> critique -> revise. Round 1 asks for the walking skeleton first; later rounds propose only what is still missing. A skeptic pass rejects vague, duplicate or premature tasks (release, licence or launch work before the skeleton works); proposals that duplicate the board, its archive or the inbox are rejected. The loop stops when a round adds nothing, when the rounds run out or at the--max-taskscap (default 10). - Repository rules bind every call (kb3w4): the brief, propose and critique prompts all carry the repo's
.claude/rules/*.md(sorted), thenAGENTS.mdandCLAUDE.md, each cut at 3000 characters and 24000 in total, with a note naming how many files were left out. The brief states each rule that limits the intent as arepoline, the proposer may not propose a task a rule forbids (a CI job on a repo whose rules forbid CI) and the skeptic rejects one that contradicts a rule, naming it. Every prompt also asks for English text whatever the intent's language or the worker's own language setting. - Every draft carries a done-when and, when one safe command can prove it, a
verifycommand (the same rules asverify:, kg93e); an unsafe one is dropped and listed in the report's notes. - It runs on the first active worker that answers, in fleet order, whatever its kind; the worker runs in an empty scratch directory with edits off. The first line it prints names the worker and the call count (two per round plus one for the brief).
- Coverage (kdmze): the brief prompt shows each line with its index, and every proposed task names
serves, the indices of the brief lines it serves (an index out of range or not an integer drops to an empty list). The report adds a## Coveragesection right after## Drafted: one- uncovered [stated] <text>line perstatedbrief line that no accepted task serves, one- orphan <title>line per task that serves no line, or the single line- every stated line is covered; no orphan task. From round 2 on, the propose prompt lists the still-uncoveredstatedlines underUNCOVERED BRIEF LINES (propose tasks for these first):.repoandassumptionlines never count as uncovered. - Assumption cap and journal (k05cf): the brief asks for at most 3
assumptionlines (DRAFT_ASSUMPTION_CAP), each with the alternative it rules out. Extra lines are kept, not dropped; the report's## Assumptionssection then opens with- over the cap: <count> assumptions, cap 3and each line reads- <text> (not: <rejected>). Without--dry-run, each assumption is also recorded in the board decision journal as an agent-taken decision with no task id (rundraft-<slug of the intent>, statusauto, bycyberine-draft/<worker>), the same entrycyberine tasks decide --runwrites, socyberine tasks statuslists them as unreviewed decisions until youcyberine tasks decide --ack <ref...>or--revert <ref> --reason "<why>". A journal write failure prints adraft: warning:line and does not fail the draft. - Output: one
draft-*.mdper task in.local/tasks/drafts/(title, do, done-when, verify, effort, intent and round), and a report in.local/tasks/drafts/reports/<date>-<slug>.<host>.mdwith the brief, assumptions, missing areas, rejected tasks and notes. In a hub submodule the drafts carry itspath:.
Known limitations. One worker answers every call; there is no multi-worker debate yet (/cyberine:plan still runs competing lists through the fleet). --prior-art reads a file; it does not start a research run. Rules are passed as text the worker is told to obey, not enforced by a parser, so a worker can still ignore one; the skeptic is the second check. Only the first 3000 characters of each rule file are read. Coverage rests on the worker's own serves indices, which nothing verifies beyond range; a task can claim a line it does not really serve. Assumptions over the cap are flagged, not trimmed. The run has not yet been measured live on a real repository.
Did the intent land? — cyberine converge (kqp11)
Every task passes its own gate (DiffJudge, its acceptance check), but nothing checks that the whole intent of a cyberine draft run landed. cyberine converge re-reads the code against that intent, treats a task's done status as no evidence, and adds only what is still missing. A run that finds no gap changes nothing.
cyberine converge # check the newest draft report
cyberine converge --report .local/tasks/drafts/reports/<date>-<slug>.<host>.md
cyberine converge --auditors 3 # three auditors instead of two
cyberine converge --dry-run # print the would-be report, write nothing
cyberine converge --include-rejected # also audit an intent whose drafts the owner rejected
cyberine tasks inbox # review the follow-up drafts it wrote-
Rejected intents (kw17e): an intent counts as rejected when
.local/tasks/drafts/rejected/holds a draft whoseintent:line names it and.local/tasks/drafts/consumed/holds none (compared case-insensitively, whitespace collapsed). Without--report, converge passes over every newer report whose intent is rejected and audits the newest one left; with--reporton a rejected intent it audits nothing. Each one passed over printsconverge: skipped rejected intent "<intent>" ..., and when nothing is left it exits 0, calls no auditor and writes nothing.--include-rejectedturns the check off, for when the owner names that intent again. -
Input: a
cyberine draftreport. With no--report, it reads the newest.mdin.local/tasks/drafts/reports/whose name does not contain-converge-. It uses the report's intent heading, its## Brieflines and its## Draftedtitles, and ignores every other section. Each drafted title is matched to a board task (manifest or archive) by title, and the auditors see that task's status,do:anddone-when:; a title with no match is shown as "not saved". -
Auditors: up to
--auditorsactive workers (default 2), different vendors first, then different kinds (seat, CLI or loop worker alike; no kind is required). Each audit runs read-only in a detached git worktree off HEAD, which is removed afterwards. The first line it prints names the auditors and the call count: one audit call each, plus up to one refute call per finding. -
Findings: each auditor returns findings of kind
missing,partial,contradictsorunrequested, each with the brief line or task it is about, its evidence (file paths, or what was searched) and a follow-up title, do and done-when. A finding with another kind, or without a title, do or done-when, is dropped. Findings are de-duplicated across auditors and against the board, the archive and the inbox, so a second run does not propose again a follow-up that is already waiting. -
Refutation: a different auditor checks each surviving finding against the code and keeps it unless it refutes it. With one auditor, that auditor refutes its own findings and the report says "single auditor". A refute call that fails keeps the finding and notes it. An auditor that fails is a note; when every auditor fails, it exits 1 and writes nothing.
-
Output: one
draft-*.mdper kept finding in.local/tasks/drafts/(title, do, done-when, and a Context section with the intent, kind, about, evidence and source report), and a report in.local/tasks/drafts/reports/<date>-converge-<slug>.<host>.mdwith the kept, refuted and dropped findings. The last two lines it prints areconverge: <N> follow-up draft(s) in the inboxandreport: <path>. When no finding survives, it writes no file and its last line isConverged: no gap found; nothing written. -
A missing, unreadable or brief-less report is an error on stderr with exit 1, and nothing is written.
Known limitations. It reads only a cyberine draft report; a board with no draft report has no intent to check against. The auditors read the code at HEAD, so uncommitted work is not seen. Whether a finding is real rests on the auditors and the refuter; nothing runs the code, so a gap that only a running app or a real screen shows is left to the acceptance checks. De-duplication is by title words, so a follow-up worded far from an existing task can repeat it. With --auditors 1, or only one active worker, no second vendor checks the findings. The rejected-intent check reads only the drafts folders, not the journal, and has no notion of time: after the owner re-drafts a rejected intent with cyberine draft, converge keeps skipping it until one of its drafts is saved (moved to consumed/) or --include-rejected is passed. The run has not yet been measured live on a real repository.
An Owner: decide task carries a default and an undo cost — default: and undo-cost: (k6zsv)
An owner decision is only useful if the owner can see what happens when they say nothing and what it costs to undo. Every Owner: decide task therefore carries two one-line fields, default: (what happens if the owner never answers) and undo-cost: (what it costs to reverse the default or the answer). A task counts as owner-decide when its title matches ^Owner: decide (any case), or starts with Owner: and the owner-mix classifier in src/tasks/ownerMix.ts reads it as a decision.
cyberine tasks add --title "Owner: decide the per-seat price" --do "Choose the monthly price." --done-when "The chosen price is noted on this task." --effort S --exec manual \
--default "The pricing page keeps its placeholder until the owner picks." --undo-cost "None before launch; one config edit and a customer notice after."
cyberine tasks set k3wf6 --default "Ship with option A." --undo-cost "One revert commit." # replace either text
cyberine tasks status --json | jq '.tasks[] | select(.default != null) | {id, default, undoCost}'
cyberine tasks save --schema # lists "default" and "undoCost"addandsaverefuse an owner-decide task that lacks either value: the exit code is non-zero, the message names every missing field (--default,--undo-cost, or the save keysdefault,undoCost) and nothing is written. A blank value or-counts as missing.cyberine tasks split(shapedecision) and theresearch recs --ownerbatch carry the same two keys;recs --ownerfills a neutral pair (skip it until answered; one revert commit once built) that you should replace with task-specific text.set --default <text>andset --undo-cost <text>replace the line;-removes it on a task that is not owner-decide and is refused on one that is. Retitling a task into an owner-decide one needs both values in the sameset.cyberine tasks statusprints both under the task line;status --jsonaddsdefaultandundoCostto every task (nullwhen absent).cyberine tasks doctorlists each open owner-decide task that lacks either field in its section 12. The check is advisory: it never changes the exit code, so a board written before this rule keeps working.
Known limitations and deferred work: the owner-decide test reads the title only (a task titled Owner: provision ... that hides a decision in its do: is not caught); the classifier can over-match an Owner: title that merely contains a word like "choose", in which case the fix is to supply the two values; both fields are free text, so the tool cannot check that the default is safe or the undo cost is true; hygiene does not report the gap, only doctor does; an exec: agent task never needs them.
A task closes on its verification command — verify: (kg93e)
A task can carry one shell command that proves it is done. With it, a close is a command's exit code, not a typed claim.
cyberine tasks set k3wf6 --verify "node .cyberine/accept/k3wf6.mjs" # attach the check
cyberine tasks set k3wf6 --verify - # remove it
cyberine tasks status done k3wf6 --evidence "rate limit ships" # runs the check first-
status doneruns the command from the task'spath:dir (the board root when it has none). A non-zero exit, a timeout (CYBERINE_TASKS_VERIFY_TIMEOUT_MS, default 600000) or a refused command closes nothing and prints the output tail; on exit 0 the evidence gains averify <cmd> exit 0; tail: ...note. -
cyberine tasks saveaccepts averifykey per task, so a draft can carry its own check. -
The fleet-engine autopilot (
cyberine autopilot --engine fleet --dispatch sync) runs the command on the winner diff in a throwaway worktree before landing it and refuses to land on a failure; a task that lands without a diff runs it on the live tree before the done flip.cyberine autopilot lanesruns it after landing and, on a failure, returns the task totodoand blocks it with the reason so the landed commit is fixed forward. The default Claude engine closes tasks throughcyberine tasks status done, so the same check applies. -
The command is one program with its arguments:
;&|<>and$(are refused (put a compound check in a script), and so are state-changing programs (git,rm,curl,cyberine,claude, ...) andpublish/deploy/pushsubcommands.set --verifyrefuses such a command when it is written. -
A verify that runs a test outside the acceptance-check budget (
selftest:*,sweep,tui:e2e,plugin-evals) is refused at close unlessCYBERINE_TESTS_GRANTED=1is set for a session the owner granted; a.cyberine/accept/check is refused when the repo setsaccept.runtoask(CYBERINE_ACCEPT_RUN=autolifts it for a session).
Known limitations. The check is only as strong as the command: true passes. There is no override flag; to close a task whose check cannot run on this machine, remove the field first (set --verify -) and say why in the evidence. stop_when from the Hermes completion contract is not a field: use a blocker: or an Owner task.
Acceptance checks — one bounded check per feature, run at close (khj8h, kxvg1, kdfw3)
Every feature task carries one executable check of its user-visible outcome; the board refuses to close the task until it passes, and closing also re-runs the checks a change can break.
Run recipe:
mkdir -p .cyberine/accept
cat > .cyberine/accept/k3wf6.mjs <<'JS'
import { spawnSync } from 'node:child_process';
const res = spawnSync('node', ['bin/app.mjs', 'login', '--user', 'a', '--attempts', '6'], { encoding: 'utf8' });
if (!res.stdout.includes('429')) { console.error(`expected 429 on the sixth attempt, got: ${res.stdout}`); process.exit(1); }
JS
printf '%s\n' '{"outcome":"the sixth login in 10 s returns 429","covers":["src/auth/"],"area":["auth"],"sealed":true}' > .cyberine/accept/k3wf6.json
cyberine tasks set k3wf6 --verify "node .cyberine/accept/k3wf6.mjs"
cyberine tasks status done k3wf6 --evidence "rate limit ships" # runs the check, the auth invariants and covered checksThe bin/app.mjs above stands for your own entry point. Runnable examples in this repo, each gating its own task: .cyberine/accept/khj8h.mjs (the gate drives a temp repo with two invariants), .cyberine/accept/kxvg1.mjs (area-less invariants) and .cyberine/accept/kdfw3.mjs (the sealed-check guard, ten cases); node .cyberine/accept/kdfw3.mjs prints accept guard: 10 cases passed.
Any runner, per-profile budgets (kwj7k, 2026-11-06). A check is any script that exits 0 on pass; the .json "runner" is a command line the script path is appended to. In a repo with pyproject.toml (or setup.py, setup.cfg, requirements.txt) and no package.json, cyberine accept add <task-id> writes .cyberine/accept/<task-id>.py and "runner": "python3" instead of .mjs; any other repo keeps .mjs with no runner. Recipe for a Python repo:
cyberine accept add k3wf6 # writes k3wf6.py + k3wf6.json with "runner": "python3"
cyberine tasks set k3wf6 --verify "python3 .cyberine/accept/k3wf6.py"
cyberine accept run k3wf6 # prints "k3wf6: pass" when the script exits 0
cyberine accept strengthen k3wf6 --base <sha-before-the-fix> --runs 1Set "runner": "pytest" or "runner": "go test" (any command line; node, bun and tsx keep working) to run the check on the repo's own test runner. The assertion report contract is unchanged: write {"assertions":{...}} to the path in CYBERINE_ACCEPT_REPORT. strengthen copies the check's own script (.mjs, .py or .sh) and its .json into the baseline tree. The per-check time limit is 90 s by default; name a profile in .cyberine/config.json (or config.local.json) to change it:
{ "accept": { "profile": "ml", "profiles": { "ml": { "checkSeconds": 900 } } } }The built-in profiles mobile (300 s), ml (600 s), embedded (300 s) and iac (300 s) apply without a profiles entry; accept.profiles.<name>.checkSeconds overrides them or defines a new name. A check over the limit fails with <script> timed out after <N>s. Known limitations: the profile sets only the per-check limit (the 30 s invariant limit and the 300 s rerun budget are unchanged); cyberine accept cover measures executed files from Node coverage only, so a Python or shell check records none; unseal and the sealed-check guard recognise .mjs, .py and .sh scripts only; the runner is a whitespace-split command line with no shell quoting.
Sealing a check (2026-11-04). There is no seal command: the writer sets "sealed": true in the .json by hand once a worker other than the builder approved the check (for a regression check, a reviewer other than the writer and the fixer), and commits it before the builder or fixer starts, since the guard protects a sealed pair only once git tracks it. A wording fix keeps the approval; a changed assertion, input or expected value needs a second review before the seal. A sealed check changes only on the owner's word, and the owner can give it in chat: run cyberine accept unseal <name> --quote "<the owner's words>" [--minutes <n>] (<name> is the check file stem; 15 minutes by default, 60 at most). It refuses when the quote is empty, when .cyberine/accept/<name>.mjs is not a sealed, git-tracked check, or inside a spawned worker or autopilot lane. On success it records an unreviewed decision in the board ledger (cyberine tasks status lists its ref) and writes .local/accept-grants/<name>.json; while that file names the check and has not expired, the plugin guard lets Edit, Write, apply_patch and shell writes through for that one check, and every other sealed check stays refused. Edit the check, then cyberine accept strengthen <name> measures it again and ends the grant; cyberine accept reseal <name> ends it without measuring (exit 0 also when no grant exists). The guard refuses any edit or shell write under .local/accept-grants/, so only unseal writes a grant. A sealed check may be read as the source of a cp, ln or install command, because for those three only the destination (the last operand, or the -t DIR / --target-directory directory, where a directory under .cyberine/accept means one file per source name) counts as a write; mv, rm, touch, truncate, tee, dd, chmod, sed -i, perl -i and > / >> redirects still count every sealed path. Setting CYBERINE_ACCEPT_ROLE=writer (the env the Claude Code session started with, or the owner's own shell, since the guard reads the hook process env) remains the alternative. The board rule cyberine-board.md is the one statement of this; cyberine sync brings it to repos that applied the rules. Known limitations: the quote is not authenticated (same as cyberine tasks accept), so the ledger records what the session says the owner said; a grant lasts at most 60 minutes; grants live in gitignored .local/, so they are per machine; the unseal decision has status auto because only auto decisions reach the unreviewed queue, and an owner status would also be copied into the generated rulings rule as a standing ruling; and nothing authenticates the approver, so writer, reviewer and fixer being different workers is not enforced.
Extend recipe: a rule for the whole app goes in .cyberine/invariants/<name>.mjs with {"area":["billing"]} in <name>.json (no area means every task); it runs before any task in that area closes, 30 s at most. "covers" takes paths, directory prefixes ending in /, or globs with * and **; a check whose covers match the task's change re-runs when that task closes; the change is everything uncommitted, untracked or ahead of the upstream, plus the files of the last 200 commits whose message names the task id (so a task committed and pushed before status done is still checked). Set "sealed": true once the check is approved: the plugin's PreToolUse guard then refuses Write, Edit, apply_patch and shell writes to it unless the session sets CYBERINE_ACCEPT_ROLE=writer. A fix task (its title starts with fix, or a fix(<id>) commit names it) that touches a file two other fix commits changed in the last 7 days closes only when some acceptance check's covers include that file; the refusal names the file and the earlier commits (CYBERINE_LOOP_BREAKER=off skips it for a session). What no agent can observe (a real OS permission, the owner's logged-in browser, a native window) is a manual check: .cyberine/accept/<id>.json carries "manual":{"steps":["..."],"record":"a Gapreel recording"}; closing the task then closes the code as built, files one Owner: check <id> on a real screen: <title> task with the steps (exec manual, depending on the task), and the evidence says pending-owner, never a live pass. cyberine draft asks for an acceptance contract per feature (script with what the check asserts, or manual with the steps); the draft file and the report show it as check (script): ..., and the A-Z recipe writes those checks before the build. cyberine next --owner ends with Terminal state: success | blocked-on-owner | held | agent-work-remains. Held-out checks the builder never sees live in .local/accept-heldout/<id>/*.mjs (gitignored, so absent from every worktree); each receives the tree under test as its first argument. They run after the visible checks pass, both at close and before a winner lands, and a red one refuses with (the builder never saw this check); the evidence records held-out N/M passed. Inside a compare run they also rank the candidate winners (see "Ranking compare winners by held-out checks" below). cyberine accept add <id> [--manual] scaffolds the check and its .json; cyberine accept run <id> runs it and writes a receipt bound to the tree hash (HEAD, the uncommitted diff and untracked files) and to the check files, under $CYBERINE_HOME/accept-receipts/; cyberine accept status [<id>] [--json] prints pass, fail, stale (the tree or the check changed since the run), never-run or pending-owner per check, and for one id exits 0, 1, 3, 3 or 4. The close gate still runs checks itself; receipts answer "is this still green" without re-running. Prove a new check bites before trusting it: cyberine accept strengthen <id> [--base <ref>] [--runs <n>] runs it n times on HEAD (default 3; mixed results mean weak-flaky), then on a git archive snapshot of the commit before the first commit naming the task (it must fail there, else weak-no-bite), and writes the verdict to .cyberine/accept/<id>.strength.json; a weak verdict refuses the task's close until the check is tightened and strengthened again. When the feature landed in another task's commit, pass that commit's parent as --base. A check that drives a browser or an app sets "heavy": true; only heavy checks wait on the machine pressure gate, so a light node check still runs while the laptop reports memory pressure. List the decision refs a check enforces in "decisions" (refs from cyberine tasks decide); when one of them is re-decided or reverted after the check was last edited, the check is stale: cyberine tasks status lists it, and closing a task in its area or covered by it is refused until the check writer edits the check. {"accept":{"run":"ask"}} in .cyberine/config.json or config.local.json stops checks from running unasked. A check exits 78 when its own setup cannot run, and cyberine accept strengthen then records unmeasured-base instead of counting that crash as a bite (kgfba). A check that node cannot load (it imports .ts sources) sets "runner": "bun" or "runner": "tsx" in its .json; cyberine accept run, strengthen and the close gate then use that runner, and an unknown value fails with a named error.
Measured covers (km4ew, 2026-11-04). Declared covers miss files a check really runs. cyberine accept cover <task-id> runs the check once under NODE_V8_COVERAGE (with the runner its .json names: node, tsx or bun, exactly as cyberine accept run does) and writes .local/accept-covers/<task-id>.json as {"files": [...]}: the sorted, repo-relative paths of the files the check executed, leaving out the check itself, anything under .cyberine/, node_modules/ and node: modules. A tsx check reports the original .ts files. The command exits 0 when the check ran, whether or not it passed (it prints the check's own exit), and exits 6 when it could not start (unknown runner, runner missing, timeout). It never edits the check's .json, so a sealed check stays untouched. From then on the covers re-run at status done use the declared covers plus the measured files, so a change to a file the check executes re-runs it even when the declared covers omit that file. A missing or malformed sidecar is ignored. Run: cyberine accept cover k3wf6. Known limitations: .local/ is gitignored, so measured covers are per machine and each machine runs the command itself; the sidecar is a snapshot, so re-run the command after the check or the code it runs moves; coverage reflects the one path of one run (a failing check lists only what it reached, and a branch the run did not take is not listed); the bun runner does not write V8 coverage data and gives an empty list.
Ranking compare winners by held-out checks (k1b6s, 2026-11-04). When a compare step has more than one candidate diff and the task has held-out checks under .local/accept-heldout/<task-id>/*.mjs, DiffJudge runs those checks per gate-passing candidate in that candidate's own throwaway worktree (each script bounded by the same 90 s limit runHeldOut uses at a close) and names the winner by held-out pass rate, not by shorter diff or the LLM tie-break. Ties on held-out rate break on visible rate, then id. The verdict rationale prints one line per candidate, <id>: visible V/T, held-out H/T, gap 0.75, where the gap is the visible pass rate minus the held-out pass rate (a high gap means the candidate fits what the builder could see and little else), then winner: <id>; the same table is stored in run.json under the step verdict as heldOutRanking (winner, ranked, excluded, report). The pure ranking is rankByHeldOut in src/judges/heldOutRanking.ts. A candidate that failed apply or the verify gate is excluded and never wins. Run recipe: put the checks in place, then run any compare that reaches DiffJudge for that board task, such as the autopilot compete lane (competePick passes the board task id); no new flag or command.
Known limitations: "visible" counts the verify gate (apply plus the typecheck or declared verify command, always 1 check) and the task's own .cyberine/accept/<id>.mjs when it exists and accept.run is not ask; covering checks and invariants are not counted. Held-out checks are looked up by the compare task's id, so a compare whose task id is not the board id has none and keeps the old ordering. When the two leading candidates tie on both rates the old tie-break runs and the table is added to the rationale. Held-out checks run one candidate after another, so many candidates with slow checks lengthen the judge step.
| Variable | Default | Effect |
|---|---|---|
CYBERINE_ACCEPT_RUN | unset (repo config, then auto) | auto runs acceptance checks at close; ask refuses them until the owner allows it |
CYBERINE_TESTS_GRANTED | unset | 1 lets a verify: that runs a selftest, sweep, tui:e2e or plugin eval run at close |
CYBERINE_ACCEPT_ROLE | unset | writer lets the session edit sealed checks and invariants |
Per-assertion strength (ka63w, 2026-11-02). cyberine accept strengthen <task-id> sets CYBERINE_ACCEPT_REPORT to a temporary file path for every run, on HEAD and on the baseline. A check that writes {"assertions":{"<name>":true,...}} there (true means that assertion passed) is measured assertion by assertion: an assertion that already passes on the baseline makes the verdict weak-empty-assertion, the output names it, and the strength file lists it under emptyAssertions; the board then refuses to close the task until the assertion is rewritten or dropped. A check that writes no report keeps the check-wide verdict and the output says check-wide only. The baseline tree gets a symlink for the repo root's node_modules and for every nested one (for example apps/web/node_modules) at the same relative path, found by a walk that skips .git, node_modules, dist, .local, .claude/worktrees and anything deeper than 6 directories. Write the report with one line at the end of the check: if (process.env.CYBERINE_ACCEPT_REPORT) writeFileSync(process.env.CYBERINE_ACCEPT_REPORT, JSON.stringify({ assertions }));. The bundled tests policy (cyberine init --policies tests) carries the owner's regression-check rule (T1-C): a worker other than the fixer writes the check from the symptom, a claim table, a review before any run, and one fail-before, pass-after measurement (k0pkp). Known limitations: an assertion that guards behaviour the change must keep (it passes before and after on purpose) is reported as empty too, so it belongs in a separate check; and the report is only as honest as the check that writes it, since strengthen cannot see assertions a check does not report.
Regression and guard assertions (ksfa5, k7v9g, 2026-11-02). A check's .json may declare its claim table as "assertions": { "<name>": { "kind": "regression" }, "<name>": { "kind": "guard", "protects": "<behaviour and why the fix could break it>" } } (owner ruling T2-A). The declaration lives in the sealed .json, which the reviewer approves; the running check only reports results. With a declaration, cyberine accept strengthen requires every report to carry exactly the declared names with true or false, a regression to be false on the baseline and true on HEAD, a guard to be true on both, and at least one regression; it prints measured (fail-before, pass-after): <names> and measured (pass-before, pass-after): <names> on a strong result. A missing, extra or non-boolean entry, a guard without protects, a check with no regression, or a HEAD run that exits 0 while its report marks an assertion false gives bad-report; a guard that fails on the baseline gives weak-mislabeled-guard; a baseline that writes no report (a crash before the report) gives unmeasured-base instead of counting as a bite. The board refuses to close on bad-report, weak-mislabeled-guard, weak-empty-assertion, weak-no-bite and weak-flaky. The strength file records tree, the digest of the working tree it measured, beside head. The baseline is a git archive of the base commit, made a git repo with one commit, plus the check's own .mjs and .json and every repo-relative path its .json lists under "companions" (a helper or fixture the task added), copied from the working tree (ktsz2). When every declared assertion, guards included, is false on the baseline, the verdict is unmeasured-base: the check most likely could not run there. The strength file then carries allFalseBaseline: true, and the board refuses to close the task on it until the companions or the labels are fixed, so a mislabeled guard cannot slip through as merely unmeasured. Known limitation: reviewer identity is not authenticated, so a declaration proves only what the reviewer actually read; a targeted mutation that turns a guard false is not run.
Policy template drift check (kgvfg). On the author's machine, npm run policy:drift runs scripts/policy-template-drift.mjs, which compares the bundled templates/policies/cyberine-policy-tests.md with the owner's spec at ~/.claude/references/project-policy-skills.md and ~/.claude/references/project-policy-skills/test-policy.md (or the directory in POLICY_SPEC_DIR; POLICY_TEMPLATE_PATH points the check at another template file). It prints the spec's Ref code:, reports each ruling id the spec names as replaced that the template still mentions as stale ruling <id> in <file> and exits 1, and lists each ruling id the spec cites as current that the template never mentions as template does not cite <id>, which is informational and exits 0. Where the spec is absent, which is every other machine, it prints policy drift: skipped, spec not found at <path> and exits 0. Known limitations: it compares ruling ids, not wording, and it runs only when called, never in the sweep or the promotion check.
Hubs and submodules (ktvab, 2026-11-02). A task's checks live in the directory its verify: runs in: <board root>/<path> when the task has a path: that exists, otherwise the board root. cyberine accept add, run, status and strengthen and the close gate all resolve from there, so from a submodule of a hub cyberine accept add <id> writes <submodule>/.cyberine/accept/ and cyberine tasks set <id> --verify "node .cyberine/accept/<id>.mjs" passes. An id that is not on the live board uses the submodule the command runs from. A check already at the board root keeps working, and accept add refuses to scaffold a second one. Closing a task with a path: runs its directory's gate and then the board root's, so hub-level invariants still hold, and accept.run: ask in either place holds the checks back. cyberine accept add prints the absolute paths it wrote, and the scaffolded script exits 1 saying it is still the scaffold until you replace it (kxk7c). Known limitations: accept status with no id from inside a submodule lists only that submodule's checks, an archived task resolves as an unknown id, and accept strengthen on a path: that is a plain subdirectory rather than a repo root archives only that subdirectory for its baseline, so a check needing the parent tree reports unmeasured-base or a false failure there.
Known limitations and deferred work. The covers re-run happens at status done (board, autopilot lanes, autonomy loop); the fleet engine's landing step still runs its own post-landing checks and does not yet run covered acceptance checks before landing (kew7k). Covers are declared unless cyberine accept cover <task-id> has measured them on this machine. The guard tells builder from writer only by the env var, so independence of the check writer is advisory in one session; shell-write detection is best effort. Staleness compares the decision's journal time with the check files' modification time, so a fresh checkout looks reviewed; it reads cyberine tasks decide entries only, not DECISION: comments in code.
Done stages and the delivery report line — status done, stages, accept
"Done" is never said bare. Every close records seven independent stages, each passed, failed, pending or n/a with evidence, how it was known (observed, read, inferred), who attested it, the revision and the time: Prepared, Self-checked, Integrated, Verified, Delivered, Observed live, Accepted. One command prints the line a report opens with.
Run recipe:
cyberine tasks status done k3wf6 --evidence "rate limit ships" # records the stages it can prove, prints the line
cyberine tasks status done k3wf6 --observed "nia, staging.example.com/login, 2026-11-03 14:10" # also attests Observed live
cyberine tasks stages k3wf6 # the delivery report line, from the latest records
cyberine tasks stages k3wf6 k7m2q --json # [{ id, stages: [...7 effective records], line }]
cyberine tasks reattest k3wf6 k7m2q # a task closed before stages existed: recompute the provable stages now, print the line
cyberine tasks accept k3wf6 --quote "Looks right, ship it" # the owner attests Accepted in their own words
cyberine tasks accept k3wf6 --quote "ok" --by nia # --by names who said it (default owner)The line has one shape, stages always in this order:
k3wf6: done-when met. Stages: Prepared passed, Self-checked passed, Integrated pending, Verified pending, Delivered pending, Observed live pending, Accepted pending. Evidence: HEAD 1a2b3c4; `node .cyberine/accept/k3wf6.mjs` exit 0. Remaining: Integrated (author), Verified (non-author), Delivered (push gate), Observed live (observer), Accepted (owner). Lives at: feature/k3wf6.Evidence joins the evidence of the passed stages; Remaining names every pending or failed stage and who clears it; a stage whose evidence went stale reads pending (stale since <sha7>); a task that is not done yet reads partly met. status done prints it right after the unchanged <id> -> [done] (...) line; cyberine tasks end prints it for every task it archives that has stage records; the autopilot's end-of-run report (both engines, and cyberine autopilot lanes) prints it for every task closed during the run, and its --json output carries them as deliveryLines.
What a close attests by itself:
- Prepared: passed with
HEAD <sha7>. - Self-checked: passed when the task's
verify:ran and exited 0; pendingno verify: commandotherwise. - Integrated: passed when verify passed, the acceptance gate did not fail, and the current branch is the default branch (
origin/HEAD, elsemain, elsemaster); otherwise pending with the reason, e.g.on branch X, not the default branch. - Verified: passed when
.cyberine/accept/<id>.strength.jsonsaysstrong(a check written by a non-builder, measured failing before and passing after bycyberine accept strengthen); pendingno measured non-author checkotherwise. - Delivered: passed when
git merge-base --is-ancestor HEAD @{u}holds; pending when the branch has no upstream or HEAD is not pushed. - Observed live: pending
not observed on the real targetunless--observednames the observer, target and time. - Accepted: always pending at close (
awaits the owner). Onlycyberine tasks acceptsets it, and it refuses (exit 2, nothing written) without a non-empty--quote, for an id that is not done (live[done]or archived), and inside a process Cyberine spawned: a CLI worker, an autopilot session or lane, or a run (CYBERINE_SPAWNED_WORKER,CYBERINE_AUTOPILOT_RUN_ID,CYBERINE_RUN_IDorCYBERINE_TASKS_NO_PROMOTEset).
Stale evidence: stages re-reads the records against HEAD. A passed stage recorded at an older revision reads pending (stale since <sha7>) once git diff --name-only <rev> HEAD touches a path the task's .cyberine/accept/<id>.json lists under covers. A task with no covers is never invalidated, however much else changed. A recording revision git no longer knows counts as stale when covers exist. Each record is one journal event of kind stages under .local/tasks/journal/; the latest record per stage wins.
Board states and the work states of the scope-of-work rule: [backlog] is parked, a derived blocked or waiting task (open deps, a blocker:, a waits-on:) is blocked, [todo] and [doing] are active, and [done] is complete only once every relevant stage has passed. The board keeps its five tokens; the delivery line's Remaining names what a closed task still needs and who clears it.
A task brief may carry a seventh section, ## Out of scope: the save item's brief.outOfScope key writes it (cyberine tasks save --schema shows the key), and tasks add --brief-file keeps one written by hand. A new brief without it saves with a warning, never a refusal. A task saved from a cyberine draft draft inherits the draft's out of scope: lines, which the draft writes into its ## Context from the intent brief's out-of-scope line.
| Variable | Default | Effect |
|---|---|---|
CYBERINE_SPAWNED_WORKER | unset | Set to 1 by Cyberine in every CLI worker and autopilot session it spawns; cyberine tasks accept refuses while it is set |
reattest is the backfill for a task closed before stage recording existed (or whose records went stale): it re-runs the task's verify: command and acceptance gate through the same code path a close uses, appends one stages event with Prepared, Self-checked, Integrated, Verified and Delivered as they read now, and prints the delivery line. It writes no status flip, edits no manifest, moves no HEAD, and never records Observed live or Accepted, so an earlier --observed or accept record stays as it was. Known limitations of reattest: it refuses, writing nothing, when a task is not done, is archived (no manifest block left to verify), or its verify: command or gate fails now; Integrated passes only on the default branch, as at a close; a task with no verify: command reads Self-checked pending.
Known limitations and deferred work. Nothing authenticates that the caller of cyberine tasks accept is the owner: the refusal only stops Cyberine's own spawned workers and lanes, so a session the owner started by hand can still run it, and the rule that an agent never accepts for the owner is a written rule (.claude/rules/cyberine-board.md), not an enforced one. Integrated reads the branch, not a merge: work merged to the default branch from a feature branch is re-attested only when it is closed (or re-closed) on the default branch. Delivered is the push gate only; a deploy is not detected. Verified trusts the strength file on disk and does not re-measure it. Invalidation looks only at the check's covers, so a task without an acceptance check never goes stale. Closes made outside status done (the fleet engine's sync-verify flip, cyberine autopilot lanes) record the same stages; a close written by any other route (a hand flip in an older Cyberine, a reconcile bake) has no stage record, and stages then reads every stage as pending.
Hub boards seen from a submodule — scope, writers and identity
A hub repo can hold the only board for its submodules. A session in a submodule reads and writes that hub board, scoped to tasks whose path: lies in the submodule.
cd hub/kite && cyberine tasks status # only kite/ tasks; notes how many are hidden
cyberine tasks status --all # every task on the hub board
cd hub/kite && cyberine tasks end --board-wide # wrap the whole hub board from here
CYBERINE_TASKS_ROOT=/path/to/hub cyberine tasks status # a root pin keeps the submodule scope- Finding the board. A submodule with no board of its own walks up the superproject chain to the nearest repo that has one, so
hub/agents/kitefinds the hub's board with scopeagents/kite; an intermediate submodule with its own board wins. A root pin (CYBERINE_TASKS_ROOT,TASKBOARD_ROOT, legacyNF_TASKS_ROOT) keeps the scope of the cwd's submodule, an invalid legacy pin changes nothing, and a pinned root gets the same scratch-dir containment check as a derived one. - Paths.
add,saveandsplitfrom a submodule stamp its scope aspath:when a task has none (split children inherit the source's path first), anddraft --to hub/kitestampspath: kite../kite,kite/and a case variant (macOS, Windows) all match thekitescope. Scopedstatus,status --json(unassigned) andnextreport open tasks with no path or a dead one apart from the in-scope list;hygienelists them as finding 7 with thecyberine tasks set <id> --path <dir>fix (pathless only when the board root has.gitmodules). - Writers. From a submodule,
end,archive,reconcile,hygiene --apply's dep repoints and merges,status donewith no id andrun --takeact only on in-scope tasks. Flips for other tasks stay pending in the overlay,endreports the done tasks it left for the hub, the hub's recurring R-blocks are not dispatched, andrelocaterefuses.--board-wideon any of them, or running from the hub root, restores the whole-board behaviour.tidy's reformatting stays board-wide. - Decisions. The unreviewed-decision list in
status,status --json, the SessionStart banner and theend --handoffseed counts only decisions on in-scope tasks (live or archived);decide --ack --allfrom a submodule acks those refs explicitly. The banner names the scope and says to read the board through the CLI; its cache key includes the root pins and the submodule topology. - Duplicates.
hygieneandsavetreat the same title anddo:on different paths as different tasks; same-path duplicates still merge. - Identity. Every board write, and
tasks init, refuses when the manifest or a sidecar dir realpaths into another git repository than the board's root (a standalone clone whose.locallinks into a hub submodule's.local, or a root pin into one), naming the real path, that repository and the hub board to use.statusanddoctorreport the same problem, and a submodule board that shadows a hub board (doctor finding 12). The PreToolUse guard compares the realpath of a Write/Edit target, so a symlink alias or a relativeACTIVE.mdcannot slip past it.
Known limitations. A decision's scope is the task's path now, not at decision time. A cwd inside a submodule's .local that is a symlink into the hub resolves hub-wide. cyberine tasks init and cyberine handoff in a submodule still use the hub's root for rules and handoffs until kj4re ships.
/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 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 vendor's sandbox approval queue"
/cyberine:tasks tidy # reconcile + archive --auto + doctor
/cyberine:tasks end # cyberine tasks end: gate on drafts, reconcile, archive all done, print the boardThe intent map it follows, and the command each intent lands on:
| Intent | Command 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ờ bên thứ ba" | 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 end | cyberine tasks end (add --force only while drafts are pending) |
Owner work versus agent work (kbkpv). Both /cyberine:tasks and /cyberine:plan shape a list so agents run everything that needs no owner input and the owner's part is visible as tasks. A decision about money, law, presence or taste is its own exec: manual task titled "Owner: decide ...", whose brief holds the options, a recommendation and a default; a technical or design decision goes to /cyberine:ask first and lands in the dependent task's brief. An external resource becomes an agent integration against an empty key template in the gitignored env file, an "Owner: provision ..." task and a "Verify ... live" task, and the integration is reported "built, live verification pending". Dependents list the owner task in deps, never in a blocker, so cyberine next shows them under waiting on deps and keeps the blocked count for conditions outside the board. cyberine next lists the "Owner:" rows among the runnable ones (it does not filter on exec); cyberine autopilot dispatches only exec: agent. Run recipe: /cyberine:tasks save these as tasks after a conversation that names a service or an open choice; when the skill meets a task that already hides a decision in a blocker it mints the owner task, runs cyberine tasks deps set <id> "<owner id>" and cyberine tasks status unblock <id>. Known limits: the shaping is the skill's judgement, not a CLI check (nothing flags a manual task that bundles agent work, or a blocker that names a decision), and cyberine next still shows owner rows as runnable.
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:
| Tier | Default | Env var | Meaning |
|---|---|---|---|
| soft | 0.35 | CYBERINE_AUTOPILOT_SOFT | Do 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 |
| hard | 0.65 | CYBERINE_AUTOPILOT_HARD | Finish the task in hand, then hand off. Take nothing new |
| emergency | 0.85 | CYBERINE_AUTOPILOT_EMERGENCY | Hand 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-policy138 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 has been stable for two poll intervals and the file carries a Where:/Next: seed or a frontmatter header (the writer may still be flushing; otherwise HandoffFileIncompleteError). 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-bridgeA 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 before writing any file surfaces as a plain timeout unless it left the status marker (HandoffWriterDiedError, see kcmnj below), a file that settles empty or without a seed or frontmatter fails with HandoffFileIncompleteError, and one that succeeds but writes to a different project's suffix surfaces as a plain timeout — 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— printspath=<dir>for$CYBERINE_HOME/handoffs(created if missing) and exits 0.cyberine handoff offload --backend <b> [--model <m>]— reads a seed from stdin (--backend/--modelare 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 awriter: cyberine-builtinfrontmatter block followed by the seed verbatim, printslog: <path>to a small log file next to it, and exits 0. The filename satisfies the samehandoff-.+_<project>\.mdpattern and 8-digit-date requirementpickNewestHandoff(src/store/handoffPick.ts) already ranks on, and the frontmatter alone satisfiesisHandoffContentComplete.cyberine handoff path— printsdir=<dir>andfile=<name>(the filenameoffloadwould use right now), so an interactive session can write a richer handoff by hand instead of piping a seed throughoffload.
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 pathVerify it:
npm run selftest:handoff-bridgeThe 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, plus optional Goal: and Verify: lines; a bare line continues the previous section):
short(default, free): writes the per-session breadcrumb<repo>/.cyberine/handoffs/<session-id>.mdstraight from the seed, no model call. A header block of- Schema: handoff/1,- Session: <id>and, when the transcript is found,- Transcript: <path>sits under the title; then## Goal(only when the seed has one),## Where(with anyVerify:lines),## Decisions,## Open,## Next(numbered),## Context.long: writes the same breadcrumb first, then starts a detachedclaude -p(the official CLI under your own login; default modelopus,--modeloverrides) 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## Fullpointer 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 andgh run list/gh run view, with write access limited to the two handoff directories, and it runs without the parent session'sCLAUDE_CODE_*variables and withoutANTHROPIC_API_KEYwhen a subscription login exists. Since 2026-09-30 it also runs with--settings '{"disableAllHooks":true,"env":{"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS":"1"}}'(the env block since kpccb), 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).
Both modes follow the shared handoff spec handoff/1 (docs/design/handoff.md, k5na3), which cc-session-float's relay batons also follow. The seed must carry a real Where and Next: an empty section, a whole-line <placeholder> or filler such as TODO, TBD or a dash is refused with exit 2, naming each failing section, and nothing is written (cyberine tasks end --handoff refuses the same way before it archives anything). none or nothing with a reason is a valid body. A Goal: line is the operator's instruction for the next session, copied verbatim into ## Goal; a hollow Goal is refused too. A Verify: line (Verify: `git rev-parse --short HEAD` -> abc1234) lands inside ## Where. The long writer's file starts with schema: handoff/1 and kind: long frontmatter and the same header block, ends ## Where with Verify lines of its own, and writes none (<why>) under Decisions when no fork was taken.
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 claude long writer loads no MCP server (kmz46): its argv carries --strict-mcp-config --mcp-config '{"mcpServers":{}}', so the user and project MCP servers are not started under it and /cyberine:end leaves no MCP server child processes under its claude -p. The writer only reads the transcript and writes markdown, so it needs none. Known limitation: this covers the claude backend only; the codex writer is a separate argv (buildLongCodexArgv) and is unchanged, and a Claude Code that stopped honouring an empty --mcp-config object would start the servers again. npm run selftest:handoff-backend asserts the flags.
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 modelVerify it: npm run selftest:handoff-write (seed parsing and the breadcrumb byte-for-byte, the handoff/1 header block, Goal and Verify lines and the seed gate, 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 seed without a real Where or Next (handoff/1 gate), 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 handoff/1 gate checks the seed only: the long writer is told to meet it in its own file, but that file is not re-checked after it is written, and Done and Decisions are not required in the seed because the writer fills them from the transcript. 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 seat fallback. The claude long writer runs on each account of the seat chain in turn (claude -p ... --output-format stream-json --verbose) instead of once on the keychain login. A hop is stopped (SIGTERM, then SIGKILL) and the next account starts when the stream shows an api_retry with HTTP 429 or a rejected rate limit, the result event is an error, the process exits non-zero, the hop outlives CYBERINE_HANDOFF_HOP_TIMEOUT_MS (default 10 minutes), or the handoff file is missing or empty; the partial file is removed first. The log gets one hop <n>/<total> <account>: <reason> line per failed hop, with the reset time when the stream reports one, and handoff written by <account> on success. "Handoff FAILED" fires only when every account failed and lists each reason; the success notice names the account.
Known Limitations and Deferred Work. --backend codex still runs once with no fallback. Only a 429 or a rejected rate limit ends a hop early, so another stall waits out the per-hop cap, and with N accounts the worst case is N times that cap. Not yet run against the real claude binary.
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 samesrc/tui/notify.tscode the fleet TUI uses (macOS, Linuxnotify-send; silent no-op elsewhere). The subtitle is the Claude Code session name (the transcript's last/renametitle, else its AI title, elsesession <short id>), with the repo appended when the name does not already contain it. Success readsHandoff ready · ended <HH:MM>with the handoff topic and the duration in the body and opens the handoff file on click; failure readsHandoff 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:buildcompilesnative/macos-notifier/main.swiftinto an ad-hoc-signedCyberine.appunder$CYBERINE_HOME/notifier/with the Cyberine icon (needs Xcode'sswiftc). 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 toosascript(Script Editor icon, no click action). The first banner from the app asks for notification permission once. Under a non-defaultCYBERINE_HOMEthe build isCyberine Dev(bundle iddev.cyberine.notifier.dev), so a dev copy never shares the installed one's identity or permission; the build first removes an old-identity bundle (bundle iddev.cyberine.notifier) at that path. Known limitation: whether the System Settings > Notifications entry for the dev copy appears and is allowed is an owner check. - Telegram, opt-in: with
CYBERINE_TELEGRAM_BOT_TOKENandCYBERINE_TELEGRAM_CHAT_IDset (shell or~/.cyberine/.env; optionalCYBERINE_TELEGRAM_THREAD_IDfor 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 sameresolveTelegramConfig+buildTelegramSendMessagechannelcyberine autopilotuses. 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 readsProject: <repo>,Start: <HH:MM:SS> · <date>,Writer: <claude|codex> · session <short id>. END addsTopic: <topic>(the# Handoff — <topic>heading the writer produced, humanized) beforeProject:, thenEnd: <HH:MM:SS> · <date> (took <duration>)andFile: <path>. FAILED readsProject:,End: ... (after <duration>),Reason: <exit code or signal, with the reason when the writer exited 0 without a file or could not start>andLog: <path>. - Delivery record: when the writer stops, the watcher appends one
handoff-watch: done|failed at <iso>; os notification sent|failed; telegram handoff-start sent|not sent, handoff-done|handoff-failed sent|not sentline to the writer's.logbeside the handoff, so whether a banner or a Telegram message went out can be checked after the fact (not sentmeans noCYBERINE_TELEGRAM_*config or the send failed; the line never carries the token). The caller's environment decides whether Telegram is used:buildWatcherEnvrecords it asCYBERINE_WATCHER_TELEGRAM=on|off, and a watcher started withoffsends nothing even though it re-reads$HOME/.cyberine/.envat start-up (before ky3tr, a caller with an isolatedCYBERINE_HOMEor scrubbedCYBERINE_TELEGRAM_*keys, such as the handoff smokes, still had its watcher post to the owner's real Telegram chat). - Forcing a failure to see the failed notice:
--backend codex --model no-such-modelfails at once (Codex rejects the model with HTTP 400). On the claude backend an unknown--modelno longer fails: Claude Code 2.1.29x only warnsunrecognized_modeland writes the handoff on its default model (measured 2026-10-08). - Environment: the writer keeps exactly the scrubbed environment it always had (no
CLAUDECODEorCLAUDE_CODE_*, no metered key when a subscription login exists), and now also losesCYBERINE_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 configuredTurn 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.
Knowledge capture (kx5xv). After the writer stops, success or failure, the watcher reads the knowledge-proposals block the writer emitted and appends each item to the knowledge ledger as an unreviewed entry (recipe under "Knowledge ledger", "Capture at session end"). It makes no model call of its own.
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)
cyberine handoff list --limit 10 # long handoffs of every project, newest first, plus this repo's newest (--json for the /cy panel)
printf 'Where: ...\nNext: ...\n' | cyberine tasks end --handoff short # wrap the board, then write the breadcrumbpullis 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 (labelledlegacy .remember, read only, never copied or migrated).pull last 0or a non-number is refused with exit 2.pointerprints 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.listis read-only and makes no model call, and unlikepullit is not scoped to the repo you are in. It lists the long handoffs in~/.cyberine/handoffs/of every project, newest first by the timestamp in the file name (--limit N, default 20;--project <name>keeps one project), with the count written today, and inside a git repo it also names that repo's newest handoff from the same three sourcespullreads.--jsonprints one object of schemahandoff-list/1, which the/cypanel's Handoffs tab reads. A bad--limitor an unknown flag is refused with exit 2.cyberine initadds.cyberine/handoffs/to the Cyberine ignore block, and on a terminal it asks whether to installcyberine handoff pointeras 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, andinitskips the question when the plugin is installed.- Two plugin skills drive it.
/cyberine:handoffwrites the free short breadcrumb: the live session composes the seed from what it already holds and pipes it tocyberine 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:endin a repo with no board skips the wrap and runscyberine handoff write --mode longalone, sincetasks endrefuses without a manifest./cyberine:end [--model <m>]closes the session:cyberine tasks end --handoff long, the board wrap plus the full handoff a backgroundclaude -pwrites; 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 readscyberine tasks unlogged,cyberine tasks statusand 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 astodo, work only discussed asbacklog, 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 anOpen:line (since 2026-09-30, operator ruling:endexists 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 throughsave, 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 wayendruns with--forceand the handoff'sOpen:line names the draft./cyberine:goroutes "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 normalendwrap, then writes the handoff. The drafts gate still stops the plain CLIendbefore anything, and then no handoff is written either;/cyberine:endasks about the draft first and passes--forceonly 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 go 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 captureIt 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:
--engine | what 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 |
claude | exactly 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 # Readiness and its reason first, then tasks, billing, budget and the stop instruction; Known Limitations: engine settings, drivers and the model plan print only with --verbose
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 autonomyA 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 (CYBERINE_TASKS_ROOT, CYBERINE_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 claudeFail 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 autopilotrouting.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>.jsonlAn 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.
An id list runs in the order given (ksrfd). cyberine autopilot c a b (and cyberine loop with an id list) dispatches c, then a, then b, on both engines, instead of re-sorting the named tasks by priority then file order; the dry-run plan lists them in the same order. A named task that is not runnable at that moment (its deps are unmet, or it is blocked) is skipped, not dropped, and is taken in list order as soon as it becomes runnable. Only an explicit id list is ordered: --area, a goal and a bare run keep the priority-then-file order. Known limitations: a duplicate id is counted once, at its first position; on --engine claude the context-budget rule can still pass over a named task that does not fit the remaining window and take a later one that does, so list order is the preference, not a guarantee against that rule. Guard: npm run selftest:autopilot-ordered-scope.
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.jsonThe 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.jsonKnown 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.
The autopilot skill triages "run every runnable task" (kg00k). When the request is the whole runnable set ("cyberine chay het tasks runable", "work the whole board unattended", a bare /cyberine:autopilot), the session does not hand the CLI "the whole board". Between the run-readiness pass (step 2) and the fork pass (step 3) it runs a triage (step 2a) from the manifest text alone. It keeps a runnable task only when an agent can verify its done-when alone, and excludes by class: eyes (the done-when needs the operator's eyes on a TUI, hub or /cy panel), live (a real service, a published package, the operator's plan or session), gate (anything behind an operator gate in the repo's loaded rules; in this repo selftests and the sweep, tui:e2e including "covered by an e2e case", promote:check, plugin evals, per the test-policy and ci-policy skills), external (publish, deploy, mail, payment) and manual. A done-when whose wording does not settle it is excluded, not guessed. The kept ids are ordered by priority (p0 before p1 before p2, a missing line counts as p1), then by how many tasks under cyberine next --json waiting[] list it in unmet, then by independent chain (tasks that share an area: tag, a source path in do: or a deps: link stay neighbours, so a parallel dispatch does not collide on files). Once cyberine next --groups (k831c) exists its groups are used verbatim; until then the chains are derived from exec, deps, area and the do: text. Those ids become the explicit scope of the real command, the facts line lists them in order and then every excluded id with its class, and nothing about an exclusion is asked. An operator-typed id list skips the triage, and an empty kept list starts nothing.
cyberine next --json
cyberine autopilot --dry-run k8a2c k3b9d k7f1e
cyberine autopilot --bg --yes --answers .local/autopilot/answers-20260924-101500.json k8a2c k3b9d k7f1eKnown Limitations and Deferred Work (kg00k). The classification is the session model's own reading of the task text, so a mislabelled task reaches the run or the exclusion list on that judgement alone; the facts line is where it is reviewed. cyberine next --groups (k831c) has not shipped, so chains come from heuristics until it does. The help router's row for this request ("The whole board, unattended") is updated under its own task. A dry run on this repo's board on 2026-10-04 kept none of the seven runnable tasks (every one needed eyes, a live check or an operator gate), which is the intended outcome rather than a failure: the run is not started.
Engine-specific flags are refused, never ignored. --driver, --attached, --batch, --on-drained, --brainstorm-max, --brainstorm-model, --on-quota, --force-model, --stall-timeout, --turn-timeout and --unfinished 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, --on-timeout, --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 neither --max-iter nor CYBERINE_LOOP_MAX_ITER was given, so a caller that already passes a budget keeps one.
Fable final check (kb4fr, opt-in). cyberine autopilot --engine fleet --fable-check (or CYBERINE_AUTOPILOT_FABLE_CHECK=1) adds one last review to the fleet engine's synchronous path. After a task's typecheck, verify: command and done-when commands pass, and before the winner diff is landed and the task closed, one claude -p call on the fable alias reads the brief, the done-when and the diff and answers one JSON object: {"verdict":"pass","summary":"..."} or {"verdict":"defect","defect":"..."}. A defect keeps the diff off the tree and the task open, and the finding becomes the task's landing failure, so the next attempt sees it. A reply that is not parseable JSON, or a failed call, counts as no verdict: the close goes ahead as today and the line says so. The verdict is printed as [autonomy-loop] sync-verify: <id> final check (fable): ..., appended to the run's diffVerification.detail, and written to .local/runs/<runId>/<id>-final-check.json. Without the flag nothing changes and no model call is made. The call uses the same Claude CLI spawn path as the judges, so it bills the Claude plan at the top tier: reserve it for hard boards.
cyberine autopilot --engine fleet --fable-check
CYBERINE_AUTOPILOT_FABLE_CHECK=1 cyberine autopilot --engine fleetKnown Limitations and Deferred Work (kb4fr). Fleet engine with --dispatch sync only (the default); --engine claude refuses --fable-check, and --dispatch bg has no diff in hand to review. The reviewer sees the first 60000 characters of the diff, not the tree around it. The model is fixed to the fable alias; there is no knob for another model yet. A task with an empty winner diff is not reviewed.
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 winsEnvironment.
| Variable | Default | Meaning |
|---|---|---|
CYBERINE_AUTOPILOT_ENGINE | fleet | default work engine for cyberine autopilot; --engine overrides it |
CYBERINE_LOOP_MAX_ITER | 100 | fleet engine: tasks one run may dispatch, when --max-iter was not given |
CYBERINE_LOOP_BLOCKED_THRESHOLD | 3 | fleet engine: consecutive no-progress iterations before stopping |
CYBERINE_LOOP_SYNC_VERIFY | unset | 1 is the same as --dispatch sync |
CYBERINE_LOOP_DEAD_FORK_LIMIT | 2 | fleet 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 -pturn 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-quotais therefore a--engine claudeflag. -
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-runprints one plan for both engines so no reader has to learn two; on--engine fleetthe driver, session-name, stall, hold, turn-cap, on-quota, batch and model-boundary lines describe--engine claudeand a dimmed note under theengineline 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_THRESHOLDis the rail for that. The counter is per-run and in-memory: a freshcyberine autopilotstarts at zero. -
--dispatch asyncobserves 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.stepsdoes not apply to it. -
A
fork:row does not fan out per approach under pins. Its approaches are appended to the task asCANDIDATE APPROACHESand 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_MODEpreset, 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: riskfrom 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 synconly. Onasynceach 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 spendNo 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 classifiercyberine run's background dispatch uses) and each fleet step's band plus anyfleet.json routing.stepspin. 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.
| Variable | Default | Meaning |
|---|---|---|
CYBERINE_SPEND_CAP_USD | unset | daily 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
PreflightIOand 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 (
PolicyTaskfromboundaryPolicy.ts) carries notitle:field, onlydo:prose, so the classifier's title input is approximated withdo:. A task whose escalating word (security / concurrency / migrations) appears only in its board title and not itsdo:line previews a class the real dispatch (which does have the title) would not pick. - Switching engine away from
fleetin the menu drops an explicit task-id or--resumescope 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--enginewas not itself explicit) Engine row to switch toclaude, a locked--areanow carries over (kfhsz), but a task-id or--resumescope 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-sessionsstays flag-only); and picking a cap here does not persist past the current process.
Long-run watcher — cyberine autopilot --watch (kznnm)
Use --watch with either autopilot engine to check long runs every ten minutes. Set CYBERINE_WATCH_INTERVAL_MIN to a positive number of minutes to change the interval; invalid values or values above 1440 use ten minutes.
cyberine autopilot --watch --yes
CYBERINE_WATCH_INTERVAL_MIN=5 cyberine autopilot --engine claude --watch --yes
cyberine autopilot --watch --bg --yesAt start the watcher names the active worker selected by the deterministic router for a trivial question. Each finding costs one short worker invocation, using subscription quota, credits or API billing according to that worker; healthy intervals make no worker call. The watcher supports seats, CLI agents and key-billed loop workers. If no worker is available, the daily spend cap blocks the call, or the worker fails, it sends the deterministic finding verbatim.
The digest contains bounded event and daemon log tails, run and lane states, and recognized run worktrees under .claude/worktrees. Detectors flag a running lane without progress for more than two intervals, three consecutive quota waits, a terminal run's leftover worktree, or two failures of the same task in the recent event tail. Each distinct finding produces one alert per sampling interval while it persists. The worker receives the finding and compact digest and is asked for 2-4 plain sentences. Secrets matching Cyberine's redaction patterns are scrubbed before worker dispatch and alert delivery.
Alerts use the existing Telegram configuration (CYBERINE_TELEGRAM_BOT_TOKEN and CYBERINE_TELEGRAM_CHAT_ID), then the existing alert webhook as a fallback. Without a working alert channel, messages go to stderr. Every alert is also recorded as autopilot:watch in the autopilot run's events.jsonl. The watcher shuts down when the autopilot call returns or throws; --watch is off by default, and --dry-run starts no watcher.
Known limitations. This watcher reports findings and never kills a worker, removes a worktree or changes the board. Event tails are bounded to 32 KiB and 80 lines per run, so older retries or quota waits can fall outside the sample. A silent but healthy long tool call can trigger the progress detector. Worktrees are associated only through recorded worker paths or the cyberine-<run-prefix>- naming convention; ambiguous or unknown owners are excluded. The watcher reads the supervisor's serve.log and serve-err.log; deployments logging only to journald or another sink have no daemon tail. Detached --dispatch async fleet jobs outlive the watcher when the parent autopilot returns. Worker invocations inherit the selected adapter's tools, sandbox and retry policy; the short-answer instruction is not a separate sandbox or a guarantee of exactly one provider request. The 60-second cancellation request relies on the adapter honoring its abort signal. Live worker billing and Telegram delivery require a separate integration check.
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/.cyberine/handoffs/handoff-20260913-101500_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 on the fleet engine (up to 100 tasks / 8 hours); the claude engine's default is 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| Flag | Default | Effect |
|---|---|---|
--max-sessions N | 6 | sessions before the loop stops with a LIMIT notification |
--max-hours H | 8 | wall-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 N | derived from the models the run will drive | window 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|opencode | claude | backend the handoff writer's offload is told to use; the builtin writer accepts and ignores it |
--handoff checkpoint|offload|none | checkpoint (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|handoff | return (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 M | 20 | minutes 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 -> 300s | k335n. 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 M | the --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 run | before the first session the command spends one throwaway sonnet turn (session cy-ap-<runid8>-probe, recorded in autopilot.jsonl with probe: true) purely to read the CLI's rate_limit_event; warns and starts anyway when the last known status is rejected and its reset is still ahead; the real first turn's failure decides (kv09w) |
--on-quota wait|stop|api | wait | what 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-model | off | make --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-spend | off | deprecated 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 M | 0 (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 N | 1 | disjoint 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|brainstorm | exit | what 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 N | 5 | ceiling on drafts per brainstorm session. Fewer is fine; zero is a legal answer |
--brainstorm-model <m> | --model, else sonnet | model the brainstorm session runs on |
--dry-run | off | print the plan and exit 0 without spawning anything |
--bg | off | on --engine claude, register the run as a claude --bg --exec fleet row (prints runId, fleet id, driver, log, watch, stop); the fleet engine, or any refusal, falls back to a detached process (prints runId, stop and watch; pid and log on stderr) |
--json | off | machine-readable output (both for --dry-run and the final summary; the final summary carries the run's result card under card, next to ownerBrief when the run ended no-runnable) |
--debug | off | let the session driver log prompt and result bodies (they can contain secrets) |
--driver print|stream|bg | print, but stream under --bg | which 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 (29 checks)
npm run selftest:autopilot-loop # case (ksb1a): the three modes end to end on the scripted driverMeasured 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'snextTaskId.
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 --engine claude --unfinished return # explicit
cyberine autopilot --engine claude --unfinished handoff # the legacy carry to one successor
CYBERINE_AUTOPILOT_UNFINISHED=handoff cyberine autopilot --engine claude # 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 driverKnown 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-policyKnown 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-stopKnown 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 is8h + 20min = 8h20m.--dry-runprints the number as awall-clock boundline.
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 --engine claude --dry-run --batch 3 # see the partition before spending anything
cyberine autopilot --engine claude --batch 2 --max-sessions 1 --max-hours 1The 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 runscyberine autopilot,npm run sweeporcyberine runneeds the working tree to itself — this repo's vibe rule forbids editingsrc/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'sdo: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 (asrc/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 --engine claude --on-drained brainstorm --brainstorm-max 3It 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 drafting session never promotes; the promotion policy does, after the run. A run brainstorms at most once and then ends (drained-brainstormed), so a draft never feeds the run that wrote it. Since 2026-10-13 (operator ruling) a brainstorm draft follows tasks.promote like any other backlog task: the next board write, or the next cyberine autopilot run, moves each draft whose deps are done and that has no blocker to todo, and that run works it. Three layers keep the drafting session itself from promoting, and the middle one was added after an adversarial review showed that gate had been prompt-deep only:
- The prompt forbids
cyberine tasks statusoutright and requires--status backlogon every add. This is instruction text, and holds only while the model obeys it. CYBERINE_TASKS_NO_PROMOTE=1, set bycyberine autopilotin the brainstorm session's spawn environment ONLY. Under itcyberine tasksREFUSES any status oftodoordoing— fromtasks status, fromtasks add --status todo, and fromtasks addwith the flag omitted (the default istodo, which was the easiest bypass). Every other caller, including the operator's own shell and an ordinary driven task session, is untouched.WRITABLE_STATUSEScapstasks add --statusattodo|backlogfor every caller, so no new block can be created asdoing,doneorforbidden(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.
Promotion policy (k7wz6, auto by default since 2026-10-05). A [backlog] task whose deps are all done and that carries no blocker: moves to [todo] on its own; nobody is asked. This is on out of the box: the setting tasks.promote defaults to auto when no config layer sets it. Turn it off for every repo in the global config, or override it for one repo:
cyberine config set tasks.promote off --global # ~/.cyberine/config.json: off everywhere
cyberine config set tasks.promote auto --local # .cyberine/config.local.json: back on for this repo
cyberine config set tasks.promote must-have-deps-met --local # this repo: only priority p0
cyberine tasks promote --dry # the policy in force, where it came from, what it would promote and why each other task stays
cyberine tasks promote # promote now: backlog -> todo, one note per task
cyberine autopilot # applies the same policy at start, before it reads the runnable setThe value is read from .cyberine/config.local.json, then .cyberine/config.json, then ~/.cyberine/config.json; the first layer that sets it wins, and with none set it is auto. Values: auto (also true or on), must-have-deps-met (the old p0-only rule), off (also false or none). An unrecognised value is a refusal naming the value and the file, never a silent grant.
Promotion runs on its own after every board write that can make a task ready: cyberine tasks status done, status unblock, add, deps set, merge and save. Closing a task therefore promotes the dependents it was holding back in the same command, so a chain advances one finished task at a time without a "promote?" question, including mid-way through an autopilot run whose workers close tasks. Each promotion is printed on stderr as <id> -> [todo] (auto-promoted: tasks.promote=auto from <source>), appends update <date>: promoted backlog -> todo by the promotion policy (tasks.promote=<value> from <source>): every dep done and no blocker to the task block, and records the same text as a journal note.
What stays in [backlog] under auto: a task with an open dep and a task with a blocker:. A draft from autopilot's own brainstorm (created-by: autopilot-brainstorm) is promoted like any other since 2026-10-13; block it (cyberine tasks status block <id> "<why>") or set tasks.promote=off to read it first. To keep a ready task out of the queue, block it: cyberine tasks status block <id> "<why>". Moving a ready task back with status backlog is not a hold, and the command warns that the next board write will promote it again.
CYBERINE_TASKS_NO_PROMOTE always wins: with it set, cyberine tasks promote, the automatic pass and cyberine autopilot promote nothing, and the brainstorm session (which autopilot spawns with that variable) can only draft.
The policy is guarded by a smoke: npm run selftest:tasks-promote-policy (operator's word only, see the test-policy skill) drives the real CLI against a throwaway board and a throwaway CYBERINE_HOME and checks the layering (built-in default, global, project, project local), the must-have-deps-met priority filter, the cascade on status done, an autopilot draft promoted like any other, the status backlog re-promote warning and CYBERINE_TASKS_NO_PROMOTE.
Known Limitations and Deferred Work (promotion policy). There is no per-area threshold. Automatic promotion runs inside the cyberine CLI only; the TaskBoard bundle (taskboard:bundle stubs fleet subcommands) keeps the old behaviour and has no promote command. A config file committed to a shared repo carries its value to every clone, so prefer config.local.json for a personal override. status backlog is not remembered as a hold; use status block. Exercised against a scratch repo by hand (default, global off, project override, invalid value, the env override, cascade on done, the autopilot-draft exclusion); no selftest covers it yet, and the autopilot start-up path was not run live.
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.
Since 2026-10-09 (kmef8) the same rule covers every model-running claude/codex spawn through one
helper, subscriptionSpawnEnv (src/core/cliSpawnEnv.ts): cyberine master (it spawned claude -p
and codex exec with the raw env before), the autopilot session drivers and fleet exec when a caller
passes no env. npm run selftest:spawn-env-scrub fails on a new claude/codex spawn site that
neither scrubs nor is allowlisted as running no model. The plugin eval harness unsets
ANTHROPIC_API_KEY before claude plugin eval unless EVAL_USE_API_KEY=1.
Known Limitations: a launcher outside Cyberine can still hand the key to the session itself. A
Bun-based launcher (such as a claude-as wrapper) auto-loads .env and .env.local from the
current directory, so a Claude Code session opened in this repo starts with ANTHROPIC_API_KEY set;
interactive Claude Code asks before using it, but any headless claude -p started from that session
outside Cyberine bills the key. Renaming .env to .env.local does not stop Bun from loading it;
it does stop Cyberine's own loader, which reads only .env.
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 printspawns 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 bgcarry 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 inrun.json's decision events asphase: "model-tier-boundary"naming both models, and it consumes one--max-sessionsslot.
--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 cy-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 cy-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 the long-handoff watcher and other tools read the session label, so
they show [cy-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 cy-ap-aca742b5-s1With --driver bg the driven session is its own attachable job, so claude agents shows TWO rows —
the wrapper cy-ap-<runId8> and the session cy-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 cy-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.
The watch: / open: / stop: hints, the claude attach / claude logs hints in driver errors and the cyberine doctor background-service hint carry CLAUDE_CONFIG_DIR='<dir>' in front when the job was launched under a non-default Claude Code config root, because every root has its own background daemon and claude agents|attach|stop|logs|rm only see the daemon of the root in their own environment; under the default ~/.claude they print bare (never CLAUDE_CONFIG_DIR=$HOME/.claude, which Claude Code treats as a separate, untrusted config). cyberine doctor probes the daemon of every known root and reports each one separately. A "Workspace not trusted." refusal from --bg names the root and says to run <prefix>claude once in that directory and accept the trust prompt. Known limitation: doctor probes only ~/.claude and the ambient CLAUDE_CONFIG_DIR; profile seat roots are not probed yet.
Profile trust preflight (k833m): before claude --bg under a profile (the launch env carries CLAUDE_CONFIG_DIR), src/autopilot/bgSessionDriver.ts calls isCwdTrustedInProfile (src/core/profileTrust.ts), which reads <profile dir>/.claude.json read-only and passes when the real path of the cwd, any ancestor, or the main repo root of a git worktree has projects[<path>].hasTrustDialogAccepted === true. Paths are compared as real paths in NFC form. Otherwise the launch is refused before claude runs, with an untrusted-cwd error that names the fix: run CLAUDE_CONFIG_DIR='<profile dir>' claude once in the repo root and accept the trust prompt, then retry. A fresh git worktree of a trusted repo passes because trust is inherited from the root. The default account and token selections skip the check. Check it alone with npm run selftest:profile-trust. Known limitations: Cyberine never writes trust into a profile's .claude.json (a running claude owns that file), so the fix is always the operator's one-time prompt; a profile that trusts a broad ancestor such as the home directory trusts every repo below it, as Claude Code does; the check is a preflight on the file as measured on Claude Code 2.1.296, so claude --bg can still refuse with "Workspace not trusted." if a later Claude Code version changes the key, and the existing post-launch untrusted-cwd mapping still catches that.
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-loopCases (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 warns (reset time in the message) and starts anyway when the last known status is rejected and resetsAt is still in the future; the real first turn's failure decides; (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.tsdrivesclaudeand nothing else, so--modelis a Claude Code model alias/id.--backendonly 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-hoursall take effect at the next turn boundary, never inside one.--max-hourstherefore 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
amemoriastub). 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 inheritedANTHROPIC_API_KEYand 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_eventwithstatus: rejected, or anis_errorresult whose text carries the rate-limit vocabulary — but whether a limit reached MID-turn ends that turn withis_error(it should: the API call fails) is recorded indocs/providers/claude-code-cli.mdas unverified until a real window runs out under autopilot. The probe model issonnet; 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/initannounced asobservedSessionId(on thetimeouterror and viadriver.observedSessionId()), and the loop resumes THAT session once withbuildWrapUpPrompt(prompt v1.1.0): stop working, commit coherent work under the repo's rules orgit stash push -u -m "autopilot partial <id>"where a commit is not pre-authorized, replyDONE <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 inautopilot.jsonlwithwrapUp: true(socyberine tasks calibratecan exclude it), and its result feeds the normal handoff. A second kill on the wrap-up itself, a kill beforesystem/initarrived, or a STOP file present at that moment still ends the sessionfailedexactly 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-minutekmq07turn 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 stuckssh/dockerconsumes 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-timeoutsurvives only as an opt-in cap. Everyautopilot.jsonlrow carriesidleMsMax, the longest quiet gap the turn showed, socyberine tasks calibratecan 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/nudgemostly 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
offloadreturned 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 thelog: <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 typedHandoffWriterDiedErrornaming 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 (~/.claudee03c3d02:{"exit":$EXIT}to<log>.status.jsonright afterEXIT=$?in the detached wrapper), so a writer that dies is seen within one poll instead of the 10-minuteHandoffTimeoutErrorthe 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
DONEand whose context never grows is handed off after 40 turns (DEFAULT_MAX_TURNS_PER_SESSION), which is a backstop rather than an operating limit. --bgre-spawns the command throughnpx tsxand prints the run id it pre-allocated (CYBERINE_AUTOPILOT_RUN_ID); there is nocyberine autopilot status— read the log path it printed, orcyberine agents. Since kwx2r, on--engine claude, that re-spawn is wrapped in aclaude --bg --execfleet row by default, soclaude agentsshows 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 cy-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 --engine claude --driver stream --max-sessions 1 --model haiku
# from any other Claude Code session on this machine:
# ListAgents -> find the row named cy-ap-<runId8>-s1
# SendMessage to that name -> lands as a new user turn in the SAME sessionA 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)
printis the default only for a foreground run. Under--bgthe default isstream(kwx2r, k378p), and it has driven a full board unattended: run3fed0a2bon 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 optionalinject?on theAutopilotDriverport); the operator-facing channel today isSendMessagefrom another Claude Code session. Acyberine autopilot replycommand is not built. - Per-turn
--modelis ignored. A streaming child carries the ONE--modelit was spawned with; a different per-turn model (a task'smodel:tier, or thesonnetquota probe) is logged once on stderr and otherwise ignored. Consequence: under--driver streamthe mid-run quota probe runs on the session's own model instead ofsonnet. Changing a live session's model would mean hand-rolling the CLI's privatecontrol_requestprotocol, which this deliberately does not do. - It runs with
--dangerously-skip-permissions, like every driver since 2026-09-09 (see "Every driver now passes" above): a streaming child registers as an INTERACTIVE session, so--permission-prompts nonealone 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 agentsfleet TABLE. That view renders daemon-supervised--bgjobs; an interactive/sdk-clisession appears inclaude agents --jsonandListAgentsonly. Under--bgthe WRAPPER is a row (cy-ap-<runId8>, kwx2r) and peek replies reach the child through it;--driver bgis what makes the session itself a row. - A stalled turn kills the child.
--stall-timeout/--turn-timeoutmean exactly what they do underprint, measured from the stdin write to the matchingresult. 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.
Reviewing what an autopilot run landed — cyberine autopilot review files a "Verify X live" task per integration (ks576)
An integration built against env config finishes as "built, live verification pending" (the cyberine-plan and cyberine-tasks shapes), but nothing used to check the landed work against the real service, so a test-double pass read as done. cyberine autopilot review closes that loop after a run: it scans what landed and files one verify task per integration, so "built" and "verified live" stay separate. It is deterministic and local. It makes no live call, reads no secret value, and needs no provider key.
What it detects, from the added lines of the landed diff only (src/autopilot/liveVerify.ts):
- a credential-shaped env key added to an env template (
.env.example,.env.sample, ...) or read in code (process.env.X,import.meta.env.X,os.environ,os.getenv, ...), such asSTRIPE_SECRET_KEYorRESEND_API_KEY(CYBERINE_*,NODE_*and plain settings likeLOG_LEVELare ignored); - an SDK or HTTP client being constructed (
new Stripe(,new S3Client(,makeOpenAIClient(), a known SDK package added topackage.jsonor imported, or an external request host (fetch('https://api.resend.com/...'); localhost, private and documentation hosts are ignored); - the landed task's area: an integration-class tag (
integration,external,webhook,oauth,provider) on a task whose diff touched product code but showed no other signal files the unsure case, named from the other area tag. A domain tag such asbillingalone files nothing.
Test files, fixtures, smokes and docs are skipped, so a test double does not count as an integration. Signals are grouped per service (STRIPE_* keys, new Stripe( and the stripe package are one Stripe integration).
cyberine autopilot review --since <start-sha>
cyberine autopilot review <runId>
cyberine autopilot review --diff change.diff --task <id> --dry-run--since <sha> works for either engine: every non-merge commit in <sha>..HEAD is scanned and a board task id in the commit message ties it to its task. <runId> scans the winning diffs a fleet run recorded in run.json (steps[].winnerOutput), skipping a step that did not finish done or that the TrustGate rejected, because that diff never landed. --diff <file> scans one unified diff, with --task <id> naming the board task. --dry-run prints the plan and writes nothing; --json prints one object. It must run inside the repo whose board it files into.
What it files, through cyberine tasks save as backlog drafts the operator promotes, following the owner-split contract in docs/tasks-manifest.md section 15 (kpnc5): Verify X live (exec: agent, sonnet) for the live check, plus a separate Owner: provision X and fill the keys task (exec: manual, model: -) whenever the integration reads env keys and no existing provisioning task covers them. The Owner task names the env file (the gitignored counterpart of the template that carried the keys, .env when none did) and each key to fill, with no value. The verify task depends on the Owner task and on the landed task, in that order, so it waits until the keys are in place. Missing keys alone never make the verify task manual: it becomes Owner: verify X live (exec: manual, model: -) only when its done-when needs the owner, which the review reads from the diff as a sign-in flow (redirect_uri, an OAuth authorize URL, signInWith..., an oauth task area), a live-mode money signal (sk_live_, livemode: true) or a UI file (.tsx, .vue, .html) in the integration, and the done-when then says the owner confirms it first-hand. An existing provisioning task is reused instead of filing a second one: the review reads complete records through cyberine tasks corpus --full --recent <n> (the extra exec, model, deps, doneWhen and blocker fields, over the whole archive rather than the 30-entry default) and accepts an Owner: provision <service> task that is not dropped or forbidden, is exec: manual, names the service as a whole word and either names every key the integration reads or names none. An open match is added to the verify task's deps; a done one adds nothing, since the keys are already provisioned. The done-when names the live check: one real call through the landed code, observed output recorded on the task. Re-running the review on the same diff files nothing new: it dedupes against the whole cyberine tasks corpus (board and archive) and skips any service that already has a live (not dropped) task whose title says verify and names it as a whole word (a Dropbox task does not cover Box); an integration with no service name is caught by the writer's own title-and-do check and reported as already on the board. If the verify task alone was dropped, a re-review files a fresh one behind the surviving Owner task. Several changes that land the same service in one review file one task carrying every signal they add, so a key added by a later commit still reaches the Owner task. The printed report states built, live verification pending for each integration and that the review made no live call.
Known Limitations and Deferred Work. Detection is a pattern scan, not an analysis: an SDK class that has no Client/SDK suffix and is not in the known-services table, and an integration that only reads a key whose name does not look like a credential, are missed, and a deliberate external call to a service the table does not know is named from its host or, failing that, the task area. When unsure the review files rather than skips, so expect an occasional extra verify task to drop with cyberine tasks drop. It files the Owner provisioning task only when it can name at least one env key, so an integration detected only from a client, a package or an area tag (no key) gets the agent verify task alone; it never checks whether the keys are already filled in the env file, so a provisioning task is filed even when the owner filled them by hand and is closed by the owner; the manual-verify reading is a pattern scan of the diff, so an owner-eyes need it cannot see (a card flow with a test key, a screen reached through a framework it does not know) still gets an agent verify task; and it never closes a verify task: closing one needs observed output from a real call. It ties a commit to a task only by a board id in the commit message or the run's step text; without one the task depends on nothing and the brief names the commit. cyberine autopilot does not run the review for you at the end of a run yet; the autopilot skill's review section calls it. Covered by npm run selftest:autopilot-live-verify (fixture diffs under scripts/fixtures/autopilot-live-verify/, plus a scratch repo and board) and, for the Owner split, reuse and idempotence, npm run selftest:owner-split-live-review.
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 run detail view (i on the run's hub row) shows 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 agentsKnown 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 per-session contextTokens that --json reports (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 card.
Fleet-visible driver — cyberine autopilot --driver bg (kddqh)
--driver belongs to the claude engine, so every command here carries --engine claude (the default fleet engine refuses it with exit 2). --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 cy-ap-<runId8>-s<N> [--model <m>] [--effort <e>] --permission-prompts none --dangerously-skip-permissions. 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-diris not passed: the job already runs in the repo root. - Every follow-up:
claude stop <jobId>, thenclaude "<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 printnote: 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 withBgSessionDriverErrorcodeforked-copyrather than driving a session nobody is watching. - The stop must SETTLE before the resume, and
claude stopreturning is not that signal. Measured 2026-09-07:claude stop <id>returns, and the job's row inclaude agents --jsonloses itspidfield, 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'spidBEFORE stopping, then waits for that pid to actually die (kill(pid, 0), bounded bystopTimeoutMs, default 60 s) plus astopSettleMsgrace (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(stategoesworking->done/blocked) plus the session transcript's ownsystem/turn_durationrecord. 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-levelassistantrecord'sinput + cache_read + cache_creation, never a sum over the turn's calls, and sidechain (subagent) records are skipped. The cwd isrealpath-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 bynpm run selftest:claude-transcript-path). Field reading goes throughreadClaudeTurnTokensinsrc/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
SendMessageall land in the same transcript as ordinary prompt records. Each complete one is measured, appended toautopilot.jsonlwithorigin: 'operator'(driven turns carryorigin: 'loop'), counted byturnCount(), and its answer text is folded into theresultTextthe loop runs itsDONE <taskId>check over. close()runsclaude stop <jobId>so an aborted autopilot never leaves an agent running unattended. It deliberately does NOTclaude rmthe job: the conversation stays openable withclaude attach <id>and resumable withclaude --resume <sessionId>.- A session boundary (kj8y2, measured 2026-09-09):
-s2really is a second, independent fleet row, and-s1really is stopped, not left running. A two-fixture-task run forced across a session boundary (one task onhaiku, the next onsonnet, so the model-tier split ends session 1 early) showedcy-ap-<run8>-s1's row flip tostate: "stopped"with itspidfield gone in the SAME poll the handoff log line appears, thency-ap-<run8>-s2appear 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-s1job — it is a freshoffloadspawn — 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 theclaude agents --json --alltable 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 --engine claude --driver bg --max-sessions 1 --max-hours 1 --model haiku
# in another terminal: the driven session is a real row (name cy-ap-<runId8>-s1)
claude agents --json --all | jq '.[] | select(.name | startswith("cy-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 --engine claude --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
resultevent, so there is nototal_cost_usd.costUsdisundefinedon every bg turn and theautopilot.jsonlcolumn 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 andmodelCallsare unaffected. - No
rate_limit_eventon a bg turn — the reading is PROBED, not carried (kp5m0). Scanned 2026-09-10 over every transcript under~/.claude/projects: the background session's.jsonlhas no rate-limit record TYPE at all (the stringrate_limit_infoappears there only inside message content), so a bg turn can never carry the reading the way a-por 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 run01d98073at 79% could not fire a second time. The bg driver now re-runs the SAME cheap probe (probeQuota, oneclaude -ponsonnet, ~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-<accountTag>.json(claude.jsonwhen no account is known), and oneprobe: truerow inautopilot.jsonlcarryingrateLimit.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 insrc/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'smaxHoursbound 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 waitstill classifies a quota-blocked turn from the result TEXT, not from the reading. --modeland--permission-modeare frozen at spawn. A background session replays its own saved options, and passing a flag on resume forks a copy. So the per-taskmodel: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--debugand ignored.autopilot.jsonl'smodelcolumn still reports what actually answered, so the mismatch is visible per turn.--on-quota apicannot 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 printwhen 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
-pchild. 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
--bgrun in a directory whose.mcp.jsonhas never been trusted parks atblockedwith1 new MCP server needs approval, and--permission-prompts nonedoes not cover that prompt. The driver reports it as atimeoutnaming the job state and pointing atclaude logs <id>; the operator either approves it once fromclaude attach <id>or spawns with--strict-mcp-config. - A turn that stops mid-way at
blockedis aneeds-inputfailure, not a success (kakt9). Measured 2026-10-02 on Claude Code 2.1.287 with the driver's own argv: a model that callsAskUserQuestionmid-turn leaves the job atblocked(statuswaiting) with noturn_durationrecord, and--permission-prompts nonedoes 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 codeneeds-input, namingclaude attach <id>. A completed turn whose row happens to readblockedstill returns normally, and an interactiveclaude agentsrow (statenull) carrying the same session id is never adopted on resume. - The private daemon files are never touched.
~/.claude/jobs/<id>/state.json,daemon/roster.jsonand the daemon control socket are read-only-by-inspection here; the driver talks toclaudethe 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 refreshDrives 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). On --engine claude, --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 — on the claude engine the fleet row is the default and the old detached spawn is the fallback; the default fleet engine always takes the detached path (it opens no steerable Claude session).
cyberine autopilot --engine claude --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 cy-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 exitsWhat --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 cy-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
--execthe process owns a real TTY, and a peek reply (Spaceinclaude agents, then type + Enter) orclaude attach <id>writes straight into it. Each non-empty line is trimmed and handed to the stream driver'sinject(text), which lands it as an OPERATOR turn on the live session —origin: "operator", notaskId, exactly asSendMessagedoes. A line that arrives before a session exists is HELD and delivered at the next turn boundary rather than dropped.claude attachforwards 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;onLinestrips those (sanitizeOperatorLineinsrc/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 printswaiting: <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
cy-ap-3fed0a2brow 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 throughsanitizeStatusText(which replaces every code point below 0x20, ESC included), andemit()appends only a newline.npx tsxwas measured on 2026-09-09 writing NO ESC to a piped stdout, which is what the daemon gives an--execjob, 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:sanitizeStatusLinereduces 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--bgrun, 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 readidlebecause nothing had reached stdout since the last quota-checkwaiting: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--attachedmode. - The log file keeps everything.
.local/autopilot/logs/<runId>.logreceives 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
--attachedstart claims.local/autopilot/locks/<runId>.json— a file written atomically (staged, thenlinked, so it never appears half-written) carrying the holder's pid and start time. A second start for the SAMECYBERINE_AUTOPILOT_RUN_IDwhile 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 theclaude stopSIGTERM path. A run with noCYBERINE_AUTOPILOT_RUN_IDmints 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 andresume: 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 derivesworking/idlefrom PTY output activity, so an autopilot that is waiting shows asworking(or goesidleafter ~30 s of quiet) and never as blocked. Thewaiting: <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. --execis undocumented inclaude --helpon 2.1.263 (the agent-view doc is what specifies it). It is registered inCLI_FLAG_COMPATwith 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.- Both
claude respawnforms replace the row on Claude Code 2.1.311; neither duplicates.claude respawn <id>, on a realcyberine autopilot --bgrow, REPLACES: 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 stayedworking, and the daemon left ONEcy-ap-*row.claude respawn --allnow does the same: measured 2026-10-27 (k0fjm, 2.1.311) on an exec row in a throwawayCLAUDE_CONFIG_DIRwhose daemon held no other job, the command logged a second pid 15 s after the first, the first pid was gone, the second was alive, andclaude agents --jsonstill listed oneworkingrow under the same id. The earlier duplicate (operator probe 2026-09-08 on 2.1.263, k5qjr: two pids under one fleet id) no longer reproduces.--allacts on every job of one daemon, so measure it only in a root with no other background job. If a later version duplicates again, the per-runId lock below still keeps the board safe. - 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 withautopilot: 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, and the service matters (k4r34). Since Claude Code 2.1.295 an exec row is stopped about a minute after the background service stops, so keep it running (
claude agentsopen in a terminal);cyberine doctorwarns when it is down whilecy-ap-,cy-run-orcy-w-rows are live. 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 plusclaude --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.jsonlbut 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/*andcontrol.sockare never written — onlyclaude --bg,--exec,stopandagents --jsonare used.
Verify:
npm run selftest:autopilot-fleet-execHermetic: 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: cy-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 — no flag selects the fleet row (--fleet only pins workers), and nothing has to be set to get a row. The one exception is a run whose fleet carries a seat-pinned id (k6hgj): it always takes the detached path and never gets 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 cy-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 cy-run-4a564c13 (6f91916b); log: .local/runs/<runId>/console.log
# ○ add a health check to the web server · 4a564c13 · queued · 0/? · 0s · $0
# next: cyberine wait 4a564c13
claude agents --json --all # the row is named cy-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 rowWhat actually happens: the shared helper launchAsFleetRow (src/autopilot/fleetExec.ts) reuses
kwx2r's probeExecCapability / launchFleetRow / parseBackgroundedId unchanged, and adds one
naming rule (runFleetRowName, prefix cy-run- instead of autopilot's cy-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 wayuseRunTokenStream.tstailsevents.jsonl(useOperatorInputTail.ts, byte offset, 1 s interval). - Web dashboard (
cyberine web):GET /api/runs/:id/operator-input(and its/v1alias) 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-rowsHermetic 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: cy-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
cy-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 happened | process exit | run.json state | claude agents row |
|---|---|---|---|
| SIGTERM to the exec'd process, kye4c fix | 0 | cancelled | done |
| SIGTERM to the exec'd process, before kye4c | 143 | cancelled | failed |
claude stop <id> | either | cancelled | stopped |
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.
Worker children die with their engine (k7psk). Every CLI worker an engine spawns (a claude -p seat web search, a skeptic or verify worker, any CliAgentAdapter worker, and the autopilot session drivers' claude turns) leads its own process group so it can be stopped as a tree, which also meant it outlived an engine that was killed: a cyberine research engine stopped mid-search left its seat searches running and spending subscription quota. Each engine process that spawns such a worker now also starts one small /bin/sh guard, detached and immune to terminal signals, that holds the list of live worker groups over a pipe. When the engine dies for any reason, SIGTERM, SIGINT, SIGHUP or even SIGKILL, the kernel closes the pipe and the guard sends SIGTERM to every listed group, then SIGKILL 2 s later, then exits; an engine that ends through process.exit also signals its groups itself on the way out. The guard installs no signal handler in the engine, so how each entry point reacts to a signal is unchanged. Deliberately detached work is not listed and keeps running: the cyberine run --bg / cyberine research background process itself, an autopilot --bg process and claude --bg fleet rows (served by the Claude daemon). Background bash jobs keep their own exit reaper (src/tools/jobRegistry.ts).
kill -TERM <research engine pid> # its seat children are gone within ~2 s of the engine exiting
npm run selftest:orphan-seat-reap # stub claude ignoring SIGTERM; SIGTERM, SIGINT, SIGHUP and SIGKILL to a research engine mid-searchKnown limitations: POSIX only; on Windows the engine still kills its worker trees on process.exit, but nothing covers a hard kill. A worker that leaves its own process group (calls setsid or daemonizes) is not reached, the same boundary as every other group kill. The guard is one extra sh process per engine for that engine's lifetime.
Known Limitations and Deferred Work (fleet-row run --bg, kb10z)
- No
Needs inputstate. Same as autopilot's--bg: the daemon derivesworking/idlefrom PTY output activity, so a quiet run just goesidle, never a distinguishable "waiting for you" state. --execstays undocumented inclaude --helpon 2.1.263 — registered inCLI_FLAG_COMPATwith 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.jsonlis 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). logPathis only populated in detached mode.BgLaunch.logPathstill 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.
Brainstorm tasks interactively — cyberine chat
cyberine run needs a task up front; cyberine chat is the interactive way to arrive at one. On a real TTY it opens a full-screen Ink TUI (a welcome banner with the model/provider/cwd + command panel, a
1. Multi-adapter demo (same task -> every available adapter, compare)
Dispatches one fixed task to all available adapters in parallel and prints a comparison table (provider | status | output | costUsd | ms) plus per-adapter detail. Proves every adapter returns the...