cyberine

Task manifest format

This is the normative specification of the shared task-manifest format used by both the cyberine orchestrator (src/tasks/manifestReader.ts, src/tasks/statusWriter.ts, src/tasks/runnable.ts on the read side; src/tasks/board*.ts behind cyberine tasks <subcommand> on the write side) and the Claude Code nf-tasks skill.

1. Scope and precedence

A task manifest is a Markdown file of ## <id> [status] <title> blocks, plus a small family of side files (an append-only status overlay, a journal, an archive, a dedupe ledger, and per-task briefs) that share its root. Two independent implementations read and write this format today: cyberine's own reader/writer under src/tasks/, and the nf-tasks skill's scripts/lib.ts (referred to below as "upstream"). Both MUST agree on wire format even though they are separate codebases with no shared dependency.

The writer-side port this document was drafted against (k8m4a) has landed: src/tasks/ now carries boardPaths.ts (the ONE resolver), boardIds.ts, boardParse.ts, boardLock.ts, boardOverlay.ts, boardReconcile.ts, boardArchive.ts, boardJournal.ts, boardLedger.ts, boardWrite.ts, and boardDoctor.ts, surfaced as cyberine tasks <subcommand> (src/commands/tasksBoard.ts, recipes in USAGE.md). This document is therefore the contract for both implementations — neither owns the format; both conform to it. Section 13 records every remaining divergence, including the clauses cyberine deliberately did not port. references/manifest-format.md, references/concurrency-model.md and references/journal.md in the nf-tasks repository remain useful implementation notes but are no longer the de-facto spec.

Format changes go spec-first: propose the change here (or in this document's PR), give the nf-tasks maintainer an FYI, then implement. A field joins the "known fields" list in section 6 only together with a real emitter — a parser that merely tolerates a field name is not sufic­ient grounds to document it as part of the format (see section 6's closing invariant).

2. File discovery and root resolution

Manifest candidates, checked in this fixed order, first existing file wins:

  1. $NF_TASKS_SYNC_DIR/TASKS.md, where NF_TASKS_SYNC_DIR defaults to .claude.
  2. <the other of .claude/.codex>/TASKS.md — whichever of .claude or .codex was not already tried in step 1.
  3. .local/tasks/ACTIVE.md — used as the resolved path even when the file does not exist yet (the "no manifest" case still names this path).

Root resolution:

  • If NF_TASKS_ROOT names an existing directory, it wins outright, ahead of every git-derived candidate below.
  • Otherwise the root is the MAIN-worktree root of cwd (a linked worktree resolves to the same root its main worktree uses, never its own .git file's directory).
  • If that root is itself a git submodule, the root becomes the SUPERPROJECT's root — UNLESS the submodule itself already has one of the three manifest candidates above, in which case the submodule keeps its own root (existence of a manifest is the opt-out signal). Neither root having a manifest keeps the submodule's own root; init MUST NOT create tracked files one directory above the session.
  • There MUST be exactly one resolver per implementation — every command in a given codebase resolves the root the same way; no command may special-case its own derivation.

Worktree detection: compare git rev-parse --git-dir against --git-common-dir — equal means not a linked worktree; different means the main worktree is elsewhere. The main worktree's root is the first worktree entry of git worktree list --porcelain, which for an ABSORBED submodule's worktree family may itself be an admin gitdir rather than a real checkout path — re-resolving --show-toplevel against that entry as a --git-dir handles that case, and a failure there means the entry was already a real working-tree path.

Storage split — three tiers anchored at different points, on purpose:

  • The overlay (status.log, status.d/, and its lock .reconcile.lock) is ALWAYS machine-local, under <root>/.local/tasks/, even for a manifest that itself lives in a synced (.claude/.codex) location. It is never committed and never synced — concurrent local writers are what it exists to serialize.
  • The journal, the dedupe ledger, and task briefs live NEXT TO THE MANIFEST, not next to the overlay: dirname(manifest)/journal/, dirname(manifest)/TASKS_DEDUPE_LEDGER.jsonl, dirname(manifest)/tasks/, dirname(manifest)/tasks/archive/. These travel with a synced manifest because they are part of the shared record, not per-machine bookkeeping.
  • The archive (TASKS_ARCHIVE.md) also lives next to the manifest, for the same reason (section 10).

Scratch-dir escape guard. When CLAUDE_JOB_DIR is set, the working directory is inside it, and the root derived from git resolves OUTSIDE it, the resolver refuses instead of returning that root: a harness scratch dir can sit inside a real repo (under ~/.claude), and anchoring would otherwise walk up out of a fixture and read or write the enclosing repo's real board. Both sides are realpath'd before comparing. NF_TASKS_ROOT is checked first and is the deliberate escape hatch for a pinned fixture; a fixture built with mktemp -d is outside every repo and never trips it. Upstream assertRootNotEscapingScratch (2026-09-16, exits 1); cyberine boardPaths.ts throws ScratchEscapeError from repoRootStrict, so every board command and cyberine next exit 1 with the message (k77ar, 2026-09-20).

3. Block grammar

A task block's header line matches ^## (\S+) \[([^\]]*)\](?: (.*))?$ — one space before the bracket, the bracket reduced to its first whitespace token. The shape is permissive on purpose: a malformed bracket is still recognisable as "a header was attempted here" and is warned about rather than silently invisible (the ghost-id tripwire, section 4, counts on it). A reader then keeps a block as a TASK only when that first token is one of the section 5 statuses; ## T1 [todo] (two spaces) is not a header at all, on every surface.

A field line matches ^([a-z][a-z-]*):\s?(.*)$ and is recognised ONLY at block scope — no leading whitespace. An indented line is a continuation of the previous field's value (or, for a multi-line list field such as fork:, a - <entry> list item), never a new field.

Every field line between a block's header and the next ## header belongs to that block, wherever it sits: prose lines, blank lines and indented lines between fields are ignored and never end the field region, and when a field name repeats, the LAST value wins. An unknown field name (field-shaped, but not in the known-fields table of section 6) is kept with a warning-only lint. A name starting with x- is the vendor extension point and never warns (section 14). This is nf-tasks' rule (lib.ts parseManifestBlocks) and cyberine's boardParse.ts port of it; until 2026-09-20 this section said the field region ended at the first prose line, which only cyberine's second reader implemented, so a block with a prose paragraph above its fields lost every field on next, autopilot and the TUI while the board and nf-tasks read them (kc71k).

Fenced code blocks (``` / ~~~, CommonMark indentation rules: 0-3 leading spaces opens a fence, 4+ is an indented code block and not a fence) are excluded from header detection entirely — a ## id [status] title line inside a fence is documentation, never a task, and must not be counted by anything that counts headers. The SAME fence-detection routine MUST be shared between the block parser and any header counter (see the ghost-id tripwire in section 4) — two independently-reimplemented fence detectors are exactly how a canonical example inside this very document's own fenced blocks could get miscounted or, worse, destructively "archived" by a naive line-range cut.

In cyberine both halves live in src/tasks/boardParse.ts: parseManifestBlocks is the block parser and countHeaders is the header counter, and both call the same exported fencedLineFlags. src/tasks/manifestReader.ts (behind next, autopilot and the TUI) reads through parseManifestBlocks itself rather than scanning fields on its own, so the board writer, the reader and nf-tasks agree by construction rather than by convention. The ghost-id tripwire built on the permissive scan is section 4.

Board format version. The preamble (every line before the first ## line, fenced lines excluded) MAY carry exactly one line <!-- board-format: <N> -->, a positive integer. A board without it is format 1, which is every board written before 2026-09-29 and every board today. cyberine tasks init and cyberine init seed the line on a new manifest; nothing adds it to an existing board, so an existing file never changes because of it. An implementation that finds a larger N than it understands MUST refuse every write (manifest rewrite, overlay flip, journal append, draft, relocate) and write nothing, and SHOULD keep serving reads. Cyberine keeps the number in one constant, BOARD_FORMAT_VERSION in src/tasks/boardFormat.ts; the refusal throws BoardFormatTooNew (exit 2) and tells the user to update whichever build they run: the full CLI says "update the cyberine CLI", the TaskBoard bundle says "update the TaskBoard plugin". The line is an HTML comment, so a reader that predates it (nf-tasks included) parses the board unchanged. A future format change bumps the constant and stamps the new number on the boards it rewrites.

4. Id families and minting

Three id families, in two disjoint namespaces:

  • T[0-9]+ — legacy sequential ids. No longer minted. A T-id resolves forever: once assigned, deps, journal entries, and archived blocks may reference it indefinitely.
  • R[0-9]+ — recurring-task ids, a namespace entirely separate from T/k task ids. An R-id is never a task and never enters the todo/doing/done flow (section 5 covers effective status only for tasks).
  • k + exactly 4 characters from the Crockford base32 alphabet, lowercase, digits and letters excluding i, l, o, u — alphabet 0123456789abcdefghjkmnpqrstvwxyz. This is the current minted shape for every new task.

Minting is pure random, never derived. A fresh id is generated by picking 4 random characters from that alphabet; NEVER by incrementing a max-seen id (sequential minting is exactly what produced duplicate-id races before this scheme). On a locally-known clash the mint regenerates and retries, up to 1000 attempts, then fails loudly — 1000 attempts against a roughly 1.05 million-id space is a backstop against an allocator bug, not a real ceiling.

Freshness check before any write. Immediately before a freshly minted id is used in ANY write (a manifest block, an archive append, or a brief file), the id MUST be checked against the union of: every id currently in the manifest, every id in TASKS_ARCHIVE.md, and every id claimed by a brief filename (tasks/<id>.md or tasks/archive/<id>.md) — this last source catches an id whose manifest/archive block no longer exists (e.g. a hand-edit that dropped the block but left the brief file behind). This check MUST run under the held reconcile lock (section 8) and MUST abort the write entirely (nothing written) on a clash — a silent regenerate at this point would mask an allocator regression instead of surfacing it.

Prefix resolution is git-style. Every id-accepting command MUST accept an unambiguous prefix of a real id (e.g. k7m2 resolves to k7m2q if it is the only id with that prefix); an ambiguous prefix errors and lists the candidates; an exact full id match always wins even when it happens to prefix a longer id.

Ghost-id tripwire. Compare the raw count of ## -anchored header lines (permissive scan, fence-excluded, section 3) against the count of headers the strict per-status parser actually recognised. A gap between the two — a header line present in the file that the strict parser silently skipped, most commonly because its id does not match either id family (e.g. a hand-typed id landing on an excluded letter i/l/o/u) — is a hard failure: it means a task exists in the file but is invisible to status, run, and archiving. In cyberine this is src/tasks/boardDoctor.ts (scanRawHeaders + hasBlockingFindings), reported by cyberine tasks doctor section 6: a gap in the LIVE manifest exits 1; an archive gap is informational, because re-iding an archived task breaks every doc and dep that cites it.

5. Status tokens and effective status

Status tokens (the bracket in a task header) are exactly one of: [backlog] [todo] [doing] [done] [forbidden].

  • [backlog] — not ready yet; excluded from runnable derivation.
  • [todo] — the default in-progress-eligible state.
  • [doing] — claimed, in flight.
  • [done] — closed.
  • [forbidden] — a permanent tombstone: an idea considered and ruled out as too dangerous, irreversible, or catastrophic to ever execute. It carries a reason: field, is excluded from runnable derivation exactly like a dependency-satisfying id is not, and is NEVER archived — it stays in the manifest forever as institutional memory. It can be flipped back to [todo] only when it was mis-classified.

Blocked is derived, never a status token. A [todo] task is "blocked" when at least one of its deps: ids is not yet effectively [done]; this is computed on every read, not stored.

Waiting is a field, not a status. A [todo] task carrying a blocker: field is "waiting" on an external, human-opened condition (a credential, a decision, a third party) rather than a machine-checked dependency. It is excluded from runnable derivation and reported separately from "blocked" so it never inflates a naive todo count. The blocker: field is the single source of truth for this state — there is no separate status to flip back; clearing the field (or replacing it with nothing) releases the task. A value of - also means no blocker, exactly as deps: - means no deps.

duplicate-of: is a tombstone, distinct from [forbidden]: the pointed-at id is the survivor. A block carrying duplicate-of: is excluded from every status bucket (runnable, waiting, blocked, doing, backlog, done, forbidden) regardless of its own status token, and stays in the manifest forever — never deleted, never renumbered. Dependency resolution checks the dep's EXACT id first (a dep on a [done] duplicate is satisfied even when its survivor is still open), then follows the duplicate-of: pointer transitively (a task depending on the dup id is satisfied once the ultimate survivor is [done]). It MUST be cycle-guarded, and a cycle is never satisfied: a dep that reaches a pointer cycle reads as unsatisfied until the board is fixed, however many members of the cycle are done. Cyberine's runnable.ts (depSatisfied) and nf-tasks' run.ts (depSatisfied) implement the same rule; until 2026-09-20 cyberine resolved a cycle to the last id seen, which made the dependents of a cycle with one done member runnable (kc71k).

An id present in the archive satisfies deps forever. A dependency is resolved by checking the union of manifest-[done] ids and every id that has ever appeared in TASKS_ARCHIVE.md — an archived task's block does not need to remain in the live manifest with its status intact for downstream deps to keep working.

Effective status = the block's base status token, OVERRIDDEN by the LAST overlay line recorded for that id (section 7). The base token in the manifest text is what a reader sees with no overlay applied; the effective status — what every consumer (run, /tasks, computeRunnable) actually uses — folds the overlay on top.

Claim lease (BOARD-CLAIM-LEASE, ky0c9). A [doing] claim is no longer permanent. cyberine tasks run stamps a lease: field (section 6) onto each claimed block — an owner (<session>@<host>) and an expiry epoch-ms — and flips the task to [doing] in the overlay, both under the reconcile lock. On every later run (still under the lock) the claim is a READ-SIDE DERIVATION, not a background daemon: any [doing] task whose lease has expired is flipped back to [todo], its lease: cleared, and one journal note records the reclaim naming the dead owner — never silently. reconcile clears a lease: line the moment a task leaves [doing], so a status done / fail / todo cannot leave a stale lease behind. DECISION (ky0c9): renewal is an explicit cyberine tasks run --renew heartbeat, not "any board write by that owner", because the latter would need every write path to thread an owner identity and re-derive that owner's holds, and it silently fails to renew a session doing long work with no intermediate write; the heartbeat needs no timer or daemon, the session issues it between work steps. DECISION (ky0c9): a session that finds its own lease expired does NOT re-claim automatically — another session may already hold the task — so status done/doing on a task the caller does not currently hold is refused with the holder's name.

6. Fields table

A field is documented here only if some command actually emits it — see this section's closing invariant. - in the Grammar column means "no accepted value / omit". Where the same field is optional and simply absent in older manifests, no backfill is performed or expected.

FieldRequired?EmitterGrammarNotes
createdyes on new blocksadd / converge apply / logYYYY-MM-DDStamped once at creation. Absent on pre-2026-08 manifests; never backfilled.
depsnoadd / converge apply- or a comma/space-separated id listIds are T\d+/R\d+/k-shape. A token that is not an id (PENDING-DECISION) is KEPT as an unsatisfied dep, so the task stays blocked, and the lint warns once per token: <tool>: manifest line <N>: deps token \` is not a task id -- the task stays blocked until it is removed -- `. Agreed with nf-tasks 2026-09-26 (upstream b5c8e15, cyberine kg2gf); before that cyberine's reader dropped the token and dispatched the task early while upstream blocked it silently.
execnoadd / converge applyagent | manual | opencodeSelects the execution surface a dispatcher targets.
modelnoadd / converge applyhaiku | sonnet | opus | fable, or - for a manual taskExecution tier, distinct from created-by: (authorship). cyberine autopilot honours it per task since 2026-09-06 (ke8hm): the task's turns run with --model <tier>, --model on the command line is only the fallback for tasks without one, --force-model inverts that.
effortREQUIRED for tasks created after this spec ships; optional on legacy blocksadd / converge applyS | M | LAn unparseable value (the historical S+M on one real board) reads as absent, never throws.
reasoningno; absent means inheritadd --reasoning / converge apply ("reasoning") / set --reasoning <level>|- / set --batch ("reasoning")low | medium | high | xhigh | maxThe reasoning effort a dispatched agent runs at, the same five levels as Claude Code's --effort. Valid only on exec: agent: every writer on both sides rejects an unknown level and rejects the field on exec: manual / exec: opencode, and set (single or --batch) refuses an exec change that would leave it stranded -- both paths share the same validator so they can never drift. Named reasoning, not a second meaning of effort, because effort is already the size estimate. Added upstream in nf-tasks 80d47be and in cyberine by kfvvf (2026-09-26); both list it in KNOWN_TASK_FIELDS, so it is also part of the fused-field alternation on both sides. set --batch (ported from nf-tasks, cyberine task k80j9) re-tiers many tasks at once from {"changes":[{"id","model"?,"reasoning"?,"exec"?,"priority"?,"effort"?}...]}, all-or-nothing, one manifest write, one journal event. Cyberine's autopilot does not yet pass it to the driven session (deferred).
prioritynoadd / converge apply (only when non-default)p0 | p1 | p2A missing line IS p1; p1 is never written explicitly. run sorts runnable tasks by (class, file position) before any --take slice.
areanoadd / converge applyone or more comma/space-separated tagsAffinity hint for grouped fan-out (same-area tasks and their dep chains cluster into one warm worker session).
pathnodraft / status doing --path / backfill-path --applyrelative POSIX path, no leading ./ or /, no .. segment, single token, or the literal - for run-from-rootDispatch cwd. Absent means "dispatch from the root", exactly as before this field existed — better empty than wrong.
created-bynoadd / converge apply / draftmodel id stringProvenance of who AUTHORED the task, distinct from model: (execution tier). Stamped once, never rewritten.
fromnodraft (carried through converge apply)<cwd-slug> <YYYY-MM-DD>, optionally @<machine>Origin-session provenance for a task drafted in one repo for another. Omitted for a task a session writes for its own repo in-line.
blockernostatus block / status unblock / converge applyfree textSee section 5. Omit when not blocked; never hand-typed outside the command that manages it.
doyes on new blocksadd / converge apply / setone-sentence imperativeThe instruction body. cyberine tasks set --do (with a required --reason) replaces it in place; it never rewrites the brief's Intent.
done-whenyes on new blocksadd / converge apply / setone-line observable acceptance criterioncyberine tasks set --done-when (with a required --reason) replaces it in place and also rewrites the brief's ## Done-when body.
whynoconverge apply (from a decision's why) / flow-project-evolve / setone line (whitespace-collapsed)A summary; full reasoning belongs in the task's brief. cyberine tasks set --why - removes the line.
everyrecurring-onlyrecurring add / recurring set (on an R-block)a duration like 7dPaired with last-done: to derive due-ness.
last-donerecurring-onlystatus did <Rxx>; also recurring add --last-done / recurring set --last-done|-YYYY-MM-DDReset point for every:.
completednolog (born-done archive block); reconcile on every [done] flip since 2026-09-14 (upstream T18, cyberine same day)YYYY-MM-DDThe date of the overlay flip that finished the task in the FLIPPER'S OWN timezone (overlay field 0 is epoch SECONDS; the flip's tz: extra, section 7, when present and valid, else the reconciler's local timezone -- epochToIsoForTz), never the time reconcile or archive ran. Relocated to the end of the field run directly before evidence: on every bake; CLEARED when a later flip takes the task out of [done]. A born-done log block keeps it where log wrote it (after created-by:), so readers must not assume position.
reasonforbidden-onlystatus forbidden <id> --reason "<why>" (both sides; cyberine since kw16p, 2026-09-27)free textExplains why the task is a permanent tombstone. A convention, not an invariant: cyberine tasks doctor section 8 (and upstream doctor section 7, k51a6) report a [forbidden] block with no reason: and a reason: left on a block that is no longer [forbidden] -- informational only, since a sanctioned status todo release leaves the same orphaned line as a hand-edit resurrection.
evidencenobaked from the overlay's 5th TSV field by reconcile (status done <id> --evidence "<text>")free textOptional by design — mandatory evidence would erode flip discipline.
duplicate-ofnomergesurvivor idNever hand-typed. See section 5.
leasenocyberine tasks run (claim/renew); cleared by reconcile and reclaim<owner> <expires-epoch-ms>BOARD-CLAIM-LEASE (ky0c9). Transient: present only while the task is effectively [doing] under a claim. owner is <session>@<host> (session from NF_TASKS_SESSION, else the agent tag; host normalised as in section 8). reconcile clears it the moment the task leaves [doing]; a reclaim (expired claim) clears it and flips the task back to [todo]. Cyberine-only for now: upstream tolerates it as an unknown field (section 14).
completedexpected but not required on archived blocks (upstream roadmap item T18; no backfill)log (today emitted only by log)YYYY-MM-DDOn a normally-archived (not born-done) block this field does not appear yet.
forkno(cyberine-only; not emitted by any command yet)multi-line - <entry> list, minimum 2 entriesT474 beam-search fork trigger: marks a task as a bucket-1 correctness/quality fork with candidate approach descriptors. Absent on every existing task.

Emitter order for a newly created block (the order a fresh ## id [status] title header's fields are written in): created, deps, exec, model, effort, reasoning, priority, path, area, blocker, created-by, from, do, done-when, why, with evidence last when reconcile bakes it. Since 2026-09-14 this order is NORMATIVE on both sides (upstream kzy55 moved converge apply's blocker above created-by to match status block's splice point and cyberine's SET_ORDER; the rule is mutable operational state above write-once provenance, so re-blocking never touches provenance lines). It is upstream's emitter order — path before area, consistent across converge.ts's renderBlock, set.ts's ORDER array, KNOWN_TASK_FIELDS and the manifest-format.md example — with cyberine's effort field inserted after model and reasoning directly after effort (upstream NORMATIVE_FIELD_ORDER, 80d47be). (An earlier revision of this document listed area before path; that was this document's error, not upstream's, and was corrected on 2026-09-06 after the nf-tasks maintainer re-checked all four upstream sites.) Cyberine emits it from renderBlock in src/tasks/boardWrite.ts (cyberine tasks add and cyberine tasks converge-apply), and setFields (cyberine tasks set) inserts an upserted field at the same position via the matching SET_ORDER array.

Closing invariant: a field belongs in this table only if some command emits it. A parser tolerating an unrecognised field name (section 3's warning-only lint) is not sufficient grounds to add a row here — that would let a hand-typed or corrupted field silently become "spec" by surviving unnoticed. Adding a field to this table always means adding a real emitter for it in the same change.

7. Overlay (status log) storage

v1 — status.log. One line per status flip, \t-separated: <epoch-seconds>\t<agentTag>\t<id>\t<status>\t<extras>. agentTag is $NF_TASKS_AGENT with whitespace replaced by _, else <user>@<pid>. The 5th field, <extras>, is a run of space-separated key:value tokens (see the next paragraph); the two in use today are evidence:<text> (only on a done flip) or, when the 4th field is literally the string todo, fail:<text> — a "tried and failed" event that returns the task to effective [todo] while recording the attempt — plus the tz: token below, which every writer now emits on every flip regardless of status. Readers split on tab and accept any line with 4 or more fields; a 3-field or malformed line is not a valid flip and is skipped; a 4-field line (no 5th field at all) is still valid and predates the tz: token.

tz:<±HH:MM> — OVERLAY-TZ-TOKEN (agreed with nf-tasks as kk2xm, 2026-09-17; cyberine implementation k2kkr). Every writer on both sides fills the extras field with tz:<±HH:MM> — its own UTC offset AT FLIP TIME, RFC 3339 form, never an IANA zone name — FIRST, ahead of any free-text key: tz:+07:00 alone on a flip with no evidence/fail, or tz:+07:00 evidence:<text> / tz:+07:00 fail:<reason>. This exists because the rsync-mirrored board is read on more than one machine: completed: and journal ts used to render in the RECONCILING machine's local timezone, so a flip made at 00:30 +07:00 could bake as the previous day on a future UTC cloud runner. Readers derive both fields from the flip's own epoch plus this offset instead of the reconciler's clock. Validity rule (agreed 2026-09-17, matches upstream's reader byte for byte): a tz: value counts as ABSENT — same as a pre-token line — unless it matches ^[+-]\d{2}:\d{2}$ with hours <= 14 and minutes <= 59; Z, a bare +7, +15:00 and +07:60 all fall back to the reconciler's local timezone. The writer never emits Z; UTC is always written as +00:00. No migration: a line written before this token existed has no tz: and renders exactly as it always did. In cyberine this is src/tasks/boardPaths.ts (localOffsetAt writes it, parseTzOffset validates it, epochToIsoForTz is the one function both boardReconcile.ts's completed: bake and boardJournal.ts's harvested done event ts call), and the token is minted by src/tasks/statusWriter.ts's setStatus so every caller (cyberine tasks status, the autonomy loop's sync-verify, the chat REPL's flip commands) gets it for free.

Reading the 5th field: left-to-right key:value tokens, first free-text key owns the remainder (cyberine ktn67, 2026-09-16). evidence and fail are FREE-TEXT keys: everything after the colon, spaces and further colons included, is their value. Any other key:value token, including tz:, is atomic (value ends at the first space) and a reader that does not know it SKIPS it and keeps scanning. This is a reader rule that predates tz: and is what let the token be added without breaking an un-upgraded reader: the previous readers tested field5.startsWith('evidence:'): a single prepended token made that test return false, so reconcile baked [done] and completed: with NO evidence: line, exit 0, nothing reported. A silent evidence drop is the worst possible failure mode for a wire-format proposal, so tolerance lands BEFORE any token is agreed. In cyberine the parser is src/tasks/boardExtras.ts (parseExtras / extraValue), shared by boardReconcile.ts and boardJournal.ts; scripts/board-contract-smoke.ts section 1 pins it, including a negative control proving the old reader misses the same line.

v2 — status.d/. One file per status flip, under <root>/.local/tasks/status.d/. A flip is written to a scratch name .tmp.<pid>.<seq> first, then renamed to its final name <13-digit-epoch-ms>-<pid>-<4-digit-seq>.log — the rename is what makes the file visible, so a file that exists under its final name is guaranteed complete; a reader MUST ignore any name still starting with .tmp.. This removes the "shared mutable file" hazard v1 has: nothing ever appends to a v2 file again after it is renamed into place, so a reconcile pass can read-and-delete it with no loss window.

v1 is still read, never written by new code — a board mid-flight (v1 lines that predate the v2 introduction, or a snapshot status.log.snap.* left by a v1-era reconcile) drains itself through the normal read path with no migration step required.

Replay order is per LINE across every source (the v1 log, any status.log.snap.*, and every status.d/*.log file), agreed with nf-tasks on 2026-09-20 (nf-tasks k29fc, cyberine kekcg): (1) the line's own field-0 epoch in whole SECONDS -- never mixed with the millisecond epoch in a v2 filename; (2) the source file's mtime in nanoseconds, oldest first -- this keeps two same-second writers in true write order, which a v2 filename's pid segment cannot; (3) the source path; (4) the line's position inside its file, so two same-second lines in one v1 file keep their append order. A line whose field 0 is not a positive integer sorts by its file's mtime in whole seconds, never as epoch 0. Ordering whole FILES by mtime (the rule before 2026-09-20) replays every line of a multi-line v1 status.log at its newest append time, so an older v1 done could beat a newer v2 todo; on a v2-only board the two rules give the same order. The last event replayed for a given id wins.

8. Lock protocol

The lock is a portable mkdir spinlock at <root>/.local/tasks/.reconcile.lock — mkdir is the atomic claim (macOS has no flock(1), so this is the mechanism available on every target platform). It guards only read-modify-write operations on the manifest (reconcile, converge apply, archive, deps rewrites, merge, and any status-affecting hand-edit); a plain status flip (append) never takes this lock.

Ownership stamp. After a successful mkdir, an owner file is written inside the lock directory holding <pid>\t<epoch-ms>. Without this stamp a lock from a process that died between mkdir and the owner-write would be indistinguishable from a live, slow critical section.

Wire change (k5grk): readers landed 2026-09-21 (Cyberine) and 2026-09-23 (nf-tasks); both tools WRITE the host from 2026-09-23. History below. The stamp grows an optional THIRD field, the owning host: <pid>\t<epoch-ms>\t<hostname>. Cyberine's reader (src/tasks/boardLock.ts readLockOwner) accepts it today and a two-field stamp still reads exactly as before, with host null. NOTHING WRITES THE THIRD FIELD YET, deliberately: both implementations must READ it before either writes it, and upstream's reader has not landed. Do not start writing a host until it has.

Update 2026-09-23. nf-tasks' reader is written (uncommitted in that repo at the time of writing). Two rules both readers now hold, and the writer must follow, before any host is written: (1) a host is compared NORMALISED -- trimmed, lowercased, a trailing .local dropped -- on both the reader side and the value written, because macOS reports X.local or X depending on the network and a verbatim compare reads this machine's own live lock as foreign; (2) after a stale lock is renamed aside, a stamped grave is validated by its WHOLE stamp string, not its pid, because a carried lock and a live local one can share a pid number. The writer step lands on both tools together.

Why the field exists: .local/ is rsync-mirrored between machines on the author's setup, so .reconcile.lock travels with it, and a carried lock was judged by pidAlive(<pid>) — a pid number from another machine's process table. A pid alive on the receiving host for unrelated reasons keeps a foreign lock wedged until someone removes the directory by hand. When a host IS present and is not this one, staleness is judged by AGE alone, because testing that pid here is a category error.

Acquisition. Spin up to 250 times at roughly 20ms intervals (a 5-second ceiling). The staleness probe runs once per acquisition attempt, not on every tick.

Staleness is TIERED, not a single OR'd condition:

  • An identified owner (pid readable and greater than 0): stale if and only if that pid is no longer alive. An EPERM on the liveness check counts as alive (cannot prove it is dead, so do not treat it as dead). This tier does NOT also check age — a live owner mid-critical-section is never stolen from just because it has run long or was suspended.
  • An unidentifiable owner (no readable pid, or pid <= 0, which is rejected outright as malformed): stale only once the stamp's age is at least 5 minutes. A missing or unreadable owner file falls back to the lock directory's own mtime for this same age test.

Breaking a stale lock renames it to <lock>.stale.<pid>.<now>, then VALIDATES that the renamed directory's owner pid still equals the pid that was judged stale — if it does not match (the real owner released and someone else's fresh lock landed in the interim), the rename is undone before proceeding, and the acquisition simply retries the mkdir instead of stealing.

For an UNSTAMPED lock that validation has to be by mtime, not by pid: both the judged pid and the renamed directory's pid are null, so a pid comparison succeeds against a DIFFERENT, live, unstamped lock that landed in the window — and destroys it. Cyberine compares the renamed directory's mtime against the mtime it judged (k5grk, fixture reproduces the destruction against the pre-fix code).

Release. Unlock only a lock this process itself owns (its pid matches the owner stamp) — never release a lock believed to belong to someone else. An UNSTAMPED lock is the trap here: a missing stamp means "unknown owner", and a reader that treats unknown as "mine" removes another process's live lock the moment that process fails to write its stamp. Cyberine tracks the lock paths it actually took in-process and refuses to remove an unstamped lock that is not among them (k5grk, fixture reproduces the deletion against the pre-fix code). A held section MUST NOT re-acquire the same lock inside itself; that deadlocks against its own claim.

9. Reconcile

Reconcile is the rare, deliberate operation that bakes the overlay back onto the manifest's base tokens. Under the lock, it MAY rewrite exactly two things in a task's header/body: the status token inside the header's [...] bracket, and an evidence: field on a block whose effective status is done.

  • Overlay sources replay in ONE mtime order, never concatenated by generation (2026-09-14, both sides). A reconcile pass that holds both a v1 status.log snapshot and v2 status.d/ files sorts the combined LINES by the per-line replay order above, exactly as fold() does for status. Concatenating [...snapshots, ...published] replayed every v1 line before every v2 line regardless of age, so an OLDER v2 flip could win over a NEWER v1 flip and a finished task silently re-entered the runnable set -- both implementations had made the same mistake independently, which is why this line exists.
  • Every manifest rewrite goes through one atomic writer that keeps mode and symlinks. writeFileAtomic (src/tasks/atomicWrite.ts) resolves the target through realpathSync, writes the temp file next to the REAL target, carries the target's mode onto it, then renames -- so a 0600 manifest stays 0600 and a symlinked ACTIVE.md keeps pointing at its target instead of being replaced by a regular file. reconcile, archive and migrate all use it; a hand-rolled writeFileSync + renameSync pair is a regression.
  • Tombstone guard covers the whole block, not only the header (2026-09-14, both sides). An overlay flip that the guard refuses on a [forbidden] header also bakes nothing else onto that block -- no completed:, no evidence: -- and clears nothing; the flip is consumed and doctor keeps reporting the tombstone. Likewise every field bake is symmetric: a pass whose final flip for an id is NOT done removes that block's completed: and evidence: lines, across passes, so a reopened task never answers "when was this finished". The bake header regex (^## <id> \[([^\]]*)\](?: |$)) accepts any bracket content, reduces it to its first token and rewrites ONLY that token ([done 2026-08-10] flipped to todo becomes [todo 2026-08-10]), so a relaxed reader can never out-run the writer and eat a flip. An UNKNOWN first token is rewritten the same way ([wip] flipped done becomes [done]); until 2026-09-20 cyberine's bake required a known token, so it stamped completed:, consumed the overlay and left [wip] in place (kc1nx). An empty bracket [] has no token and is never rewritten, on both sides. Since 2026-09-20 (kyq3x) cyberine also propagates that header refusal to the whole block, as for a tombstone: a pass with any flip for a [] id bakes no completed:/evidence: and clears nothing, consumes the flip, and prints one stderr line per id, cyberine tasks: reconcile discarded a <status> flip for <id> -- its header bracket is empty ([]), so there is no status to rewrite; repair the bracket and flip it again, where <status> is the final folded value. A refused header is also skipped by the JOURNAL harvest on the same pass, tombstone and empty bracket alike: the journal is append-only, so a flip the bake discarded would otherwise assert forever that the task was completed while the manifest says nothing happened (kbg0y, 2026-09-20; upstream's harvestJournalEvents states the same invariant as kjkac and guarded only the forbidden half, cyberine guarded neither). The harvest also skips a done flip for an id that is in neither the manifest nor TASKS_ARCHIVE.md (an orphan flip, which reconcile discards with its warning): upstream's kjkac bakeableIds guard, ported by cyberine kg2gf on 2026-09-26, so a flip for a typo'd id never leaves a permanent done journal event. The journal PREVIEW both sides expose (pendingJournalEvents, read by the journal and timeline surfaces) reaches the same verdict for the same reason, so nothing shows a task as done that reconcile will refuse to bake. Unlike the silent tombstone refusal the stderr line is mandatory, because the discarded flip is a real completion and, before this change, the wrong completed: line was its only trace. Upstream landed the same shape and wording the same day (f595c88), so the two reconciles agree again: measured 2026-09-20 on a six-block fixture with a [] done flip and a [] todo flip, both tools wrote byte-identical manifests and the same stderr text (upstream colours its copy red). Upstream's refused set is a Map<id, "forbidden" | "empty-bracket"> where cyberine keeps a Set plus emptyBracketFlips() for the printing half; same behaviour, different carrier.
  • Tombstone guard. A [forbidden] block's bracket is never overwritten by reconcile except via an explicit todo overlay line anywhere in the pass — a [forbidden] task does not silently drift to another status just because some overlay entry exists for its id.
  • Evidence is last-wins across the overlay lines being folded, but a later flip carrying no evidence CLEARS a previously-recorded one — evidence is not additive or sticky once a plain flip supersedes it. That includes a final done flip with no evidence: it removes an evidence: line an EARLIER pass baked onto the block (cyberine kc1nx, 2026-09-20, measured byte-identical to upstream on the same fixture).
  • Orphan overlay ids — an overlay line for an id no longer present in the manifest (deleted, or archived out from under it) is dropped, with a warning; it is not an error that aborts the whole pass.
  • Header-count guard. Before and after the rewrite, the raw permissive header count (section 3) MUST match the strictly-parsed count, and the pass refuses on a mismatch or on empty output — a reconcile that would silently drop or duplicate a block must abort instead of writing.
  • Write mechanics. Same-directory temp file (<manifest>.rec.<pid>) then rename — never a direct in-place rewrite — so a crash mid-write leaves the original manifest untouched rather than half-written.

In cyberine this is src/tasks/boardReconcile.ts (bakeOverlay, bakeEvidence, reconcileLocked, reconcile), surfaced as cyberine tasks reconcile. cyberine tasks reconcile --dry-run reports what a pass would bake — pending line count, whether any manifest byte would change, the folded id set, the evidence fields, and any orphan ids that would be discarded — and writes nothing. src/tasks/manifestReader.ts still reads the overlay non-destructively on every call (section 7) and never bakes.

10. Archive and brief lifecycle

TASKS_ARCHIVE.md lives next to the manifest, opens with the header # Tasks Archive: <basename(root)>, and is APPEND-ONLY. Archiving a [done] block moves it byte-for-byte — the exact source lines (lines.slice(start, end).join("\n") + "\n\n"), not a re-serialization — so no formatting normalization can silently alter an archived block's content. The manifest is rewritten afterward with exactly one trailing newline.

Write order matters: archive first, then manifest, both via same-directory temp-file-then-rename. A crash between the two writes leaves a task recorded in BOTH files — this is the one state that genuinely needs a human, and recovery is always to delete the duplicate from TASKS_ARCHIVE.md (never from the manifest), because the manifest is the side that was not yet confirmed rewritten. Archiving via --auto is a no-op below 10 accumulated [done] tasks — this threshold exists so archiving does not disturb a small, actively-referenced board on every single completion.

Every live task created by converge apply carries a brief (kg2gf, 2026-09-26, matching upstream). A batch entry with no brief object is refused (brief is required) and nothing is written, and each of the six sections must carry real content: a bare N/A, None, nil, null, TBD, TODO, -, — or an ellipsis is refused in every section, and decisions / rejected / gotchas accept None — <why> instead so an executor can tell a considered empty section from a skipped one (upstream a3ead40). Upstream has no add: converge apply is its only path to a live task. Cyberine's cyberine tasks add still accepts a task with no brief (its --brief-file is optional) and its internal planner writer (nlGoalPlanner) does not require one; both are cyberine-only paths, and the brief-quality warning for add is task k5n66.

Briefs (tasks/<id>.md) move to tasks/archive/<id>.md alongside their task, never deleted. A filename collision at the destination is reported, never silently overwritten. A sweep pass covers every brief whose task is already archived, not only the ids the current archiving run happened to process — a brief left behind by an earlier partial run is still picked up later.

Born-done blocks (written directly to TASKS_ARCHIVE.md by log, with no manifest block ever existing) reproduce this exact layout: created:, exec: manual, model: -, an optional created-by: when authorship is known, completed:, do:, then done-when: Done outside the task flow; logged retroactively.

In cyberine this is src/tasks/boardArchive.ts (splitDone, appendToArchive, archiveLocked), surfaced as cyberine tasks archive [--auto]; born-done blocks come from bornDoneBlock/logDone in src/tasks/boardWrite.ts via cyberine tasks log. Both writes go through the same-directory temp-file-then-rename helper (writeFileAtomic), archive first, and cyberine tasks log appends its journal done event only after the archive write has returned.

note <id> and brief note <id> (k0j99, 2026-09-26, ported from upstream's scripts/cmd/note.ts / scripts/cmd/brief.ts). Two purely additive annotation verbs, neither of which touches status, fields, or the brief's six converge sections. note <id> "<text>" appends one line below the manifest block's own fields: update <YYYY-MM-DD>: <text> — the line is deliberately not field-shaped (key: value), so parseManifestBlocks and every consumer read past it. brief note <id> "<text>" appends - <YYYY-MM-DD>: <text> to the brief's ## Updates section, creating that section directly above the brief's first heading (or at end-of-file when the brief has none) the first time it is used; entries land oldest-first, and the section is otherwise never rewritten by anything else. Both accept an unambiguous id prefix (resolveIdPrefix, section 4) and both refuse the same three ways: a recurring R<NN> id ("only annotates task blocks"), an id that resolves in the union of live-manifest and TASKS_ARCHIVE.md ids but is not live ("is archived -- only live tasks take notes"), and an effectively [forbidden] id ("is [forbidden] -- tombstone, refusing"), from the bracket or from an overlay flip. Cyberine added that third refusal first, for the same reason setFields/set already refuses a tombstone; upstream's note.ts mirrored it in 22ef437, so both sides now refuse it. brief note additionally refuses a task with no brief file at all, pointing back at note for the manifest-block equivalent. Both record the same text as a journal note event ({ kind: "note", id, title: "note <id>: <text>" } / "brief note <id>: <text>") — this note event kind is journal-generic and pre-dates this task (set, drop and hand-written cyberine tasks journal entries all use it too); the verb name and the journal kind are two different things that happen to share a word. In cyberine this is src/tasks/boardNote.ts (resolveLiveTaskId, parseIdAndText, appendUpdateLine, addBriefUpdate), dispatched by cyberine tasks note / cyberine tasks brief note in src/commands/tasksBoard.ts. Not yet ported: upstream's note/brief note exit 1 on a refusal; every cyberine board verb already exits 2 on a BoardRejected validation failure (section header's own "Validation refusals exit 2" convention), so these two follow the local convention rather than upstream's exit code.

11. Journal and dedupe ledger

Journal. One append-only JSONL file per local calendar day, journal/<YYYY-MM-DD>.jsonl, next to the manifest (not next to the machine-local overlay). Each line is { ts, kind, id?, title?, body?, by?, v?, evidence? } where kind is one of done, decided, note, or title. ts is ISO-8601 WITH a UTC offset (e.g. 2026-08-16T14:03:07+07:00), and the day file an event belongs to is ts.slice(0, 10). For a done event harvested from a status flip (section 7), ts is the flip's own epoch rendered at the flip's tz: extra when present and valid, else the reconciler's local timezone (epochToIsoForTz, OVERLAY-TZ-TOKEN, k2kkr) — never the reconciling machine's clock at harvest time. A hand-written event (cyberine tasks journal, log) stamps ts at write time in the writer's own local timezone, since it is not derived from an overlay flip. Events are deduped by the key (kind, id, ts) and read back sorted ascending; a corrupt or unparseable line is skipped, never a fatal read error.

Explicitly NOT specified, rejected upstream: a per-block judged-by: field. A retiering/re-judging action is represented as a single journal note event, not a persistent manifest field.

Dedupe ledger. TASKS_DEDUPE_LEDGER.jsonl, next to the manifest, one JSON object per line: { a, b, verdict, date } where verdict is merged or rejected. A lookup for a pair checks both (a, b) and (b, a) orderings — pair order is meaningful only for a merged verdict (a is the id that pointed at survivor b), not for rejected (an unordered "these were reviewed and are not duplicates"). Multiple entries for the same unordered pair are read in file order and the LAST one wins — a recorded verdict is never contradicted by treating an earlier line as authoritative once a later one exists for the same pair.

In cyberine the journal is src/tasks/boardJournal.ts (written by reconcile's harvest and by cyberine tasks log; hand-written entries via cyberine tasks journal; read by cyberine tasks timeline, which folds in the events a reconcile WOULD harvest right now so its output is the same before and after a pass). The ledger is src/tasks/boardLedger.ts, written by cyberine tasks merge / merge --reject.

12. Legacy boards

Two pre-this-format board shapes MUST still be readable by any implementation, so a repo does not need a migration step before any command can see its tasks:

  • List-item shape: - [done] T1 — title (a bold id is optional; the separator before the title may be a hyphen, en dash, or em dash).
  • Heading shape: ## T001 — Title with **Do:**-style body lines.

Detection is line-anchored and gated on a minimum-lines threshold on purpose: a document that merely mentions a task id in prose (a changelog entry, a design note referencing "T05") is NOT a board and must never be treated as one, and a deliberately-retired manifest must never be resurrected by a detector that is too eager. The next command that WRITES the board converts it in place, leaving a timestamped backup and touching only the recognised task lines — non-task prose in the same file is left untouched.

In cyberine this is src/tasks/boardParse.ts (parseLegacyBoard, migrateLegacySource, ensureMigrated): parseManifestBlocks merges recovered legacy blocks under any canonical ones it already found, src/tasks/manifestReader.ts does the same for its own ManifestTask output, every locked write path auto-converts through lockAndMigrate, and cyberine tasks migrate [--dry] does it deliberately. See also Compatibility notes on upstream's "five board resolvers" (T11).

13. What a second implementation gets wrong

Three defects were found on 2026-09-14 in BOTH implementations independently, each with a green test suite on both sides and no error path in either. They share one shape: a tolerant reader and a strict writer drifted apart, and nothing forced them to agree. Read this section before porting the format again.

  • Overlay sources replayed by generation instead of by age. [...v1 snapshots, ...v2 status.d] looks natural and is wrong: an older v2 flip beats a newer v1 flip and a finished task silently re-enters the runnable set. Sort the COMBINED lines by the per-line replay order and use that one comparator (orderOverlayLines in src/tasks/boardOverlay.ts) for status, reconcile and the second reader.
  • Line scanners that ignore code fences. The strict parser and the header counter honoured fences; the ghost-id raw scan, fold() (behind status / next), the lint, the journal title lookup, the overlay bake and the orphan-id check did not, so a ## <id> [status] line inside a fenced example became a phantom task in the counts, a lint warning about documentation, and a header reconcile could rewrite. Share ONE fence detector (fencedLineFlags) across every scanner; cyberine's interop smoke pins a fenced T99 as a phantom to no surface. The same holds for every in-block FIELD edit, not only header scans: until 2026-09-20 cyberine's set, status block / unblock, deps set, merge and reconcile's completed: / evidence: strip matched field lines inside a fenced example, so block wrote the real blocker into the fence (the reader never saw it and the task stayed runnable) and unblock / deps set / an un-done reconcile deleted the example's lines. They now go through dropFieldLines and fence-aware scans in boardParse.ts / boardWrite.ts (k15kf, npm run selftest:fence-field-writers).
  • Field bakes that can only add. evidence: and completed: were inserted on a done flip and never removed, so a reopened task kept answering "when was this finished". Every bake is symmetric: the pass computes each id's FINAL status and clears the fields of any id that is not done; a flip the tombstone guard refuses bakes nothing at all.

Two more that one side had and the other did not, kept here for the same reason: a bracket-tolerant reader paired with an exact-token bake regex eats flips ([done 2026-08-10] is read as done but never rewritten, and the overlay file is consumed anyway); and an atomic write that ignores realpath and mode replaces a symlinked manifest with a regular file and resets 0600 to 0644.

14. Compatibility notes

Every known divergence between this specification and either implementation, with its upstream tracking id where one exists. None of these are silently resolved in one direction — they are stated so both sides can converge deliberately.

Provenance: every claim in this section was verified against upstream source on 2026-09-06, and the bullets that name 2026-09-16 were re-verified that day against upstream a13d565 — scripts/lib.ts, scripts/cmd/*.ts and scripts/__tests__/*.test.ts in the nf-tasks repository, read directly rather than inferred from its prose docs, with the nf-tasks maintainer re-checking the contested ones. That pass found the error in one direction worth naming: the emitter-order bullet below recorded a divergence that did not exist, because THIS document had the order wrong. A claim here that names a specific upstream file and symbol was read at that path; where a bullet says "verified", that is what it means.

  • Malformed bracket tolerance: resolved on both sides 2026-09-14; an unknown token renders differently since 2026-09-15. Upstream ks99d reduced fold()'s bracket to its first whitespace token and made the title optional; src/tasks/boardParse.ts's parseManifestBlocks reduces the bracket to its first token and flags malformed, and cyberine's reader (src/tasks/manifestReader.ts, which reads through it) keeps the block when that token is known, so ## T1 [done 2026-08-10] x and ## T1 [todo] count on both sides. An unknown single token ([dne]) is where they part. Upstream kd57y (commit 30e2b9f, re-verified 2026-09-16 in scripts/cmd/status.ts) keeps the block parsed and flagged and now also SHOWS it in status under an [UNKNOWN STATUS] section with the raw token, deriving the parser's accept set and the render's buckets from one exported TASK_STATUSES. Cyberine still refuses the block in manifestReader.ts, so it stays invisible to the listing/runnable surfaces (cyberine next, /tasks, autopilot) and is reported by cyberine next's parser warnings and cyberine tasks doctor section 1. Not ported: widening the reader changes what every consumer treats as a task, and upstream's own commit notes the two sides reached the same destination by opposite mechanisms on purpose; a port would copy the render, not the parser. An EMPTY bracket (## k1 []) parted the other way until 2026-09-20 (kc71k): upstream run.ts read it as todo and would dispatch it while its own warning said the header needs a hand-fix. Upstream 821f114 fixed that (b.status ?? "" in run.ts, unknown in board.ts, parser untouched), so both tools now refuse it everywhere -- cyberine in fold, bake and reader; upstream in run, board and reconcile.
  • Draft basename validation: resolved on both sides. Upstream kmh2c (commit a13d565, scripts/cmd/converge.ts) and Cyberine's converge-apply reject path separators and match the drafts/ listing, so a case mismatch on a case-insensitive filesystem is refused rather than archiving a file the author did not name.
  • Two-write archive atomicity (k99ba) and archive/journal ordering (kcncc) -- the archive-before-manifest, temp-file-then-rename discipline section 10 specifies. Cyberine conforms: src/tasks/boardArchive.ts routes both writes through writeFileAtomic. Upstream now does too (re-verified 2026-09-16): scripts/cmd/archive.ts's writeAtomic delegates to lib.ts's writeFileAtomic, which kgfk2 (2026-09-15) made the one writer for every manifest write, carrying the target's mode and resolving a symlinked board instead of replacing it; the archive is appended before the manifest is rewritten. Per-write atomicity only, on both sides: the pair is still not one transaction, which upstream's ArchivePhase reports. kcncc's upstream status was not re-read in that pass.
  • Manifest resolution upstream: one resolver plus a hook twin (T11, closed 2026-09-16 in a13d565). Upstream resolves the manifest in ONE TypeScript function, lib.ts's resolveManifestPath. scripts/tasks-inject.sh keeps a bash twin because the SessionStart hook must decide whether a repo has a board before paying bun's start, and scripts/__tests__/manifest-resolver-parity.test.ts holds the two together; the twin now honours NF_TASKS_SYNC_DIR, which it used to ignore. Cyberine has no hook, so its single resolver is src/tasks/boardPaths.ts (root-resolution bullet below). Legacy-board detection (section 12) is a separate concern: this document specifies the two shapes that must be readable, and upstream's parseLegacyBoard stays the reference for anything beyond them.
  • effort is a cyberine field that upstream accepts as foreign. Re-verified against upstream source 2026-09-16: since d5612ae (2026-09-14) upstream's lib.ts lists effort in FOREIGN_TASK_FIELDS, which feeds the unknown-field lint only and never KNOWN_TASK_FIELDS or the fused-field alternation, and scripts/__tests__/lint.test.ts pins that effort: S lints clean because cyberine emits it. Cyberine keeps it in boardParse.ts's KNOWN_TASK_FIELDS, satisfying section 6's closing invariant through real emitters (cyberine tasks add and cyberine tasks converge-apply both REQUIRE it), but since 2026-09-20 (k0c7r) keeps it OUT of the fused-field alternation like upstream (FOREIGN_TO_UPSTREAM_FIELDS), so both tools warn on the same lines. Both sides accept any x--prefixed field wholesale as the extension point for a second writer (cyberine since k0c7r, VENDOR_FIELD_PREFIX): x-note: and x-ledger: lint clean everywhere. Bare note: and ledger: still warn on both sides, having no emitter on either.
  • lease is a cyberine-only field upstream tolerates as unknown (ky0c9). Upstream's parseManifestBlocks keeps unknown field lines last-wins and its lintManifest warns "unknown field" without failing, and every upstream write path (reconcile, set, deps, merge, archive) is a line-level edit or a byte-for-byte move that preserves an unknown lease: line — so a cyberine claim on a shared board does not make nf-tasks misread it, it only produces the standing unknown-field warning. Cyberine lists lease in its own KNOWN_TASK_FIELDS (section 6's closing invariant — the run emitter exists) but NOT in FOREIGN_TO_UPSTREAM_FIELDS, because upstream has no foreign entry for it. The reclaim itself is a cyberine read-side derivation under the lock; upstream neither reclaims nor renews leases, which is a divergence, not a corruption.
  • completed: is baked at reconcile on both sides (T18). Upstream d5612ae (2026-09-14) added completed to KNOWN_TASK_FIELDS and bakes completed: <YYYY-MM-DD> onto a done block at reconcile from the flip line's OWN epoch, never from when reconcile or archive runs, and deletes it again when the task is reopened. Cyberine's src/tasks/boardReconcile.ts does the same (bakeCompleted, dated from the overlay line's epoch). log's born-done blocks carry it too, written straight to the archive. Neither side backfills archives that predate the bake.
  • fork: is cyberine-only. The T474 beam-search trigger field does not exist in upstream's field set or scripts/lib.ts at all.
  • Emitter order: no divergence. An earlier revision of this document pinned area before path and recorded a divergence from upstream on that basis. Verified against upstream source 2026-09-06 (cmd/converge.ts's renderBlock, cmd/set.ts's ORDER, lib.ts's KNOWN_TASK_FIELDS, and the references/manifest-format.md example — all four agree on path before area): the contract was wrong, upstream was not. Section 6 now states path then area and cyberine's renderBlock / SET_ORDER implement it; there is nothing left to diverge on.
  • Overlay line splitting: a port choice, not a format change. Verified against upstream source 2026-09-06: upstream's reconcileLocked tab-splits overlay lines, while its fold() whitespace-splits and reads only indices 2 and 3. The two agree in practice because the agent tag has whitespace collapsed to _ and neither reader looks past index 3. The port tab-splits in EVERY reader (boardOverlay.ts, boardReconcile.ts, boardJournal.ts) for one behaviour rather than two — section 7's stated rule. No wire bytes change either way.
  • Root resolution in cyberine: resolved. src/tasks/boardPaths.ts is now the ONE resolver section 2 requires; manifestReader.ts and statusWriter.ts delegate to it instead of carrying their own --git-common-dir copies. It checks the NF_TASKS_ROOT pin first, derives the main-worktree root from --show-toplevel / --git-dir / --git-common-dir, handles the worktree list --porcelain first-entry-is-a-gitdir quirk, anchors a submodule to its superproject unless the submodule has its own manifest, and builds candidates through manifestCandidates() so NF_TASKS_SYNC_DIR is honoured in both the order and the paths. One visible consequence: the root is now the symlink-resolved --show-toplevel path (the same one upstream derives), so ManifestTask.manifestPath and setStatus's logPath come back as realpaths. That is load-bearing, not cosmetic — two tools spelling the root differently would take different lock paths and stop serialising against each other.
  • Fenced-code-block exclusion and the ghost-id tripwire in cyberine: resolved. fencedLineFlags (src/tasks/boardParse.ts) is shared by the block parser (which manifestReader.ts reads through since kc71k) and the header counter; the tripwire is src/tasks/boardDoctor.ts, reported by cyberine tasks doctor section 6.
  • Converged independently, 2026-09-14 to 2026-09-16: fence-aware scanners and one status list. Fences: cyberine 20010255 (2026-09-14) made every board line scanner share fencedLineFlags; upstream kamfq (0a04f48, 2026-09-15) and ktx1e (a13d565, 2026-09-16) did the same for fold(), tidy, group and the archive done-id scan. Status list: cyberine k856a put the canonical list in the leaf src/tasks/taskStatus.ts (re-exported by manifestReader.ts); upstream k734v (a13d565) derived six retyped copies from lib.ts's TASK_STATUSES. Neither changed wire bytes, and the two lists hold the same five tokens in a different order, which nothing depends on.
  • Submodule-scoped listing: resolved 2026-09-16 (upstream k1dhx, cyberine kgvmh). Inside a manifest-less submodule that reads its superproject's board, both tools list only the tasks whose path: lands in the submodule, print the scope and the hidden count on stderr in the same shape, and restore the whole board with --all. Cyberine: submoduleScope / pathInScope in src/tasks/boardPaths.ts, applied by scopeRunnableSets to cyberine next and chat /next. Upstream scopes status and its banner; neither side scopes execution (run upstream; next --dispatch, loop, autopilot here). npm run selftest:tasks-interop half two runs both tools in one submodule fixture and compares the task set.
  • Several spec'd fields parse-safe but still do not surface in cyberine's ManifestTask. Cyberine's ManifestTask (manifestReader.ts) carries deps, exec, model, blocker, do, done-when, area, path (added by kgvmh for the submodule scope), fork, effort, reasoning (added by krf5m for cyberine tasks group's per-group label, exec: agent only, same VALID_REASONING grammar boardWrite.ts already enforced on write), priority, created, duplicate-of. Fields this document specifies as part of the format — created-by, from, why, evidence, reason, every, last-done, completed — are read by the same parseManifestBlocks pass (section 3) but not attached to the returned ManifestTask. The BOARD side does use them: parseManifestBlocks keeps every field-shaped line last-wins, which is how cyberine tasks corpus, merge, set and deps reach why / path / duplicate-of / evidence. Widening ManifestTask beyond the optional path was left out of the port deliberately — it changes a shape several consumers destructure, for no clause this document requires.
  • group — ported (krf5m, 2026-09-26). src/tasks/boardGroup.ts behind cyberine tasks group [--workers N] [--json] mirrors upstream scripts/group.ts's behaviour, not its code: the same fixpoint pool (todo, no blocker:, no duplicate-of tombstone, every dep satisfied or riding along in-pool), the same two-level union-find (dependency components, then affinity clusters by explicit area: or >= 2 shared title/do: tokens), the same capacity-packed bin-fill into K groups, and the same priority-then-size ordering, with a group's reasoning its most demanding member's level. Verified against upstream on a shared fixture board (never a real board) run through both CLIs: identical stats, group membership, area/token clustering and per-group reasoning/priority. Two deliberate shape differences, not behaviour differences: cyberine's GroupedTask OMITS reasoning/doneWhen when absent (upstream always emits them, "" when unset) — consistent with how every other optional ManifestTask field already behaves on this side; and manifest in the JSON envelope is cyberine's own resolved path (.claude/TASKS.md / .local/tasks/ACTIVE.md), never upstream's NF_TASKS_SYNC_DIR candidates. Not ported: the oc-fanout dispatch group_workers=<K> hint line upstream's human output prints — cyberine has no oc-fanout — and no board.html groups tab (cyberine tasks board's Known Limitations note in USAGE.md).
  • evidence: / fail: overlay breadcrumbs in cyberine: resolved. setStatus gained an optional extra (plus v1Mirror: false, used by the board so a board-originated flip publishes only the v2 file upstream also writes, instead of double-counting itself across both storages). cyberine tasks status done <id> --evidence "<text>" and cyberine tasks status todo <id> --fail "<reason>" emit the 5-field line, and reconcile bakes the evidence: half onto the done block. Before OVERLAY-TZ-TOKEN below, omitting extra produced byte-identical output to every caller that predates the option; setStatus now always fills the 5th field with at least tz:<offset>, so every flip is 5 fields.
  • tz: overlay token adopted on both sides: resolved (OVERLAY-TZ-TOKEN, kk2xm upstream / k2kkr cyberine, 2026-09-17). Operator-accepted proposal from nf-tasks (kk2xm): the rsync-mirrored board is read from more than one machine, and rendering completed: / journal ts in the reconciling machine's local timezone shifted the finisher's own day by one on a future UTC cloud runner. setStatus (src/tasks/statusWriter.ts) now writes tz:<±HH:MM> as the first token of every flip's 5th field, ahead of evidence:/fail:, using the writer's own UTC offset AT FLIP TIME (boardPaths.ts's localOffsetAt, keyed off the flip's own epoch, not Date.now(), so DST at flip time is what is recorded). boardReconcile.ts's bakeCompleted and boardJournal.ts's harvestJournalEvents both derive their date/timestamp from epochToIsoForTz(epoch, extraValue(fields, 'tz')), which falls back to the pre-token reconciler-local rendering (epochToLocalIso) whenever tz: is absent or fails the validity rule in section 7. No migration: a line predating the token renders exactly as before. Options considered and rejected before this: rendering everything in UTC (a 00:30 +07:00 flip would read as the previous day on the very machine that did the work); IANA zone names (unnecessary — the offset alone is exact for a past instant and needs no tzdata); a day:<date> variant (withdrawn after nf-tasks measured its own journal stores a full local timestamp with offset, not a bare date, so day: would fix completed: and leave the same bug one layer down in the journal); rewriting old lines (never — the overlay is append-only and shared with a second writer).
  • Clauses cyberine deliberately did NOT port -- corrected 2026-09-27 (kd70e): the previous revision of this bullet was wrong about which of these are absent. Re-verified against source on both sides: status forbidden, status did <Rxx>, status doing --path, relocate --to sync|local, migrate, and end are ALL already present in cyberine tasks, most since the initial port -- only backfill-path was genuinely missing, and suggest/tidy/move were genuinely missing until this task ported them (kd70e). Specifically:
    • status forbidden <id...> exists (WRITE_STATUSES in src/commands/tasksBoard.ts includes forbidden; it flips through the same appendStatus/reconcile path as done/todo/etc). Its --reason "<why it must never run>" flag was ported by kw16p (2026-09-27) with upstream's shape: the flip lands in the overlay, reconcile runs at once, and under the board lock setForbiddenReason (src/tasks/boardStatusExtras.ts) writes reason: <text> directly under the now-[forbidden] header, replacing any earlier reason: line in that block (fenced lines untouched) instead of stacking a second, then journals one note event titled forbidden <id>: <text>. Without --reason the flip still succeeds and stderr names the missing reason (<id>: no reason: recorded -- add one with: status forbidden <id> --reason "<why it must never run>") for every id whose block has none. An empty --reason value, or --reason on any other status, is refused with nothing flipped; cyberine exits 2 there where upstream exits 1, the board-wide refusal convention.
    • status did <Rxx> exists (src/tasks/boardRecurring.ts's recurringDid, wired at status did in src/commands/tasksBoard.ts) and stamps/updates last-done: exactly like upstream.
    • status doing --path <id...> exists (src/tasks/boardStatusExtras.ts's stampPaths, wired via the --path flag on a doing flip) and stamps path: from the caller's cwd relative to the board root, same as upstream.
    • relocate --to sync|local [--dry] exists (src/tasks/boardShare.ts's relocateBoard, wired as the top-level relocate subcommand, not nested under status) and matches upstream T15's move-plus-gitignore-rewrite behaviour.
    • migrate [--dry] exists (src/commands/tasksBoard.ts's runMigrate, calling boardParse.ts's parseLegacyBoard/migrateLegacySource/ensureMigrated) and is a close line-for-line port of upstream scripts/cmd/migrate.ts, including the ghost-id report and the --dry preview. Still genuinely absent: backfill-path's assisted path: stamping from .gitmodules submodule paths -- there is no cyberine command that infers path: from an area: token matched against declared submodules; the only path: writers are draft, converge-apply and status doing --path (add cannot set it).
    • end [--force] exists (src/commands/tasksBoard.ts's runEnd) and mirrors upstream scripts/cmd/end.ts's draft gate / reconcile+archive / in-flight-doing report / final board print, including the same exit codes.
    • suggest, tidy and move were ported by this task (kd70e): src/tasks/boardSuggest.ts, src/tasks/boardTidy.ts, src/tasks/boardMove.ts, wired as top-level subcommands in src/commands/tasksBoard.ts. suggest's board-health section reuses boardStatusView.ts's existing healthLines (backlog-rot/age/stale-doing/recurring) rather than re-deriving it, and reports hook: not-applicable in every case -- cyberine has no SessionStart hook to detect (see the root-resolution bullet above), so that rung of upstream's ladder never fires here. Where upstream's ladder names a converge/install-hook step cyberine does not have, this port's ladder names cyberine's own equivalent command (gather) instead of inventing one.
    • Genuinely still not ported: backfill-path (no equivalent command; see above). The recurring writers (recurring add / set / drop) and the run/claim dispatch of overdue exec:-bearing R-blocks were ported by ktcrv on 2026-09-27; set --batch (k80j9), forget (knfg2), group (krf5m) and note / brief note (k0j99) were ported the same day.
    • cyberine tasks doctor's section 9 ("Held flips") is cyberine-only, with no upstream counterpart: it reports a status.log flip reconcile is holding open because the header's own bracket is [] (empty), a state upstream's own bake/reader pair never produces the same way (refusedHeaderIds/escapedForbiddenIds cover a different case -- the [forbidden] tombstone and the empty-bracket refusal, not a held flip waiting on a bracket repair). Informational only; never fails the report.
    • cyberine tasks converge-apply consumes a decided JSON batch and makes no dedupe or triage judgement of its own; blocker: field writes were ported 2026-09-13 (k3c00) as a thin pass-through, not a judgement.

Verification status

Sections 2, 3, 4, 6, 7, 8, 9, 10, 11 and 12 are implemented on BOTH sides and are verifiable in this repo: src/tasks/board*.ts plus src/commands/tasksBoard.ts here, scripts/lib.ts + scripts/cmd/*.ts upstream. Two smokes pin the clauses that fail silently when they regress:

  • npm run selftest:tasks-write — the add -> set -> status done --evidence -> reconcile -> archive chain in a throwaway repo, the born-done log layout byte-for-byte, 20 concurrent add processes minting 20 distinct ids with no lost block, the header-count guard, the [forbidden] tombstone guard, and the orphan-overlay stderr warning.
  • npm run selftest:tasks-lock — tiered staleness (a dead-pid stamp is broken; a live one is not, even at 10x the age threshold), pid <= 0 rejected as an identity, the ABA rename-back validation, conditional release, and re-entry failing inside the spin ceiling instead of deadlocking.

Cross-implementation evidence (2026-09-06, against this repo's real board: 43 manifest headers, 931 archive headers) — cyberine tasks doctor and upstream nf-tasks doctor produced byte-identical reports apart from the tool-name banner line, and cyberine tasks reconcile --dry-run counted the same 23 pending overlay lines an independent line count over status.log + status.d/*.log reports, leaving every file on the board byte-identical.