<!--
  scaffold: spawn-log.md
  bucket: work-management (Bucket 2)
  what belongs here: the spawn-log discipline.
    - Location and shape.
    - Update-at-dispatch-time (not at session end).
    - Fields per entry.
    - Update-outcome-when-work-returns.
    - The shared open rule every reader uses.
-->

# spawn-log discipline

## Location and shape

`agents/<paName>/spawn-log.json`. The PA writes; the operator reads. The file takes one of three shapes:

- a top-level JSON array of dispatch entries;
- an object with a `spawns` array (the shape a new instance starts with: `{ "version": "1.0", "spawns": [] }`);
- an object with an `entries` array, or with both `spawns` and `entries`.

Every reader in the platform (`pa board check`, the workspace Delegations and Status pages) goes through one core reader, `@stravica-ai/engineering-pa-core/src/spawn-log/read.mjs`. It merges both arrays by `id`: an `entries` record replaces a `spawns` record with the same id, and within one array the last record of a repeated id is its latest. A record with no `id` is kept as it is.

## Shape per entry

Minimum fields (camelCase):

- `id` — dispatch identifier. Suggested pattern: `d-YYYY-MM-DD-NNN`.
- `timestamp` — UTC ISO string of when the dispatch fired.
- `agent` — agent-id (from the Agent tool response) if available; otherwise `null` at dispatch time and back-filled on return.
- `role` — the target role name (`writer`, `librarian`, `system-architect`, etc.).
- `tier` — the engineering tier if applicable (`tool-developer` / `web-developer` / `product-engineer` / `rcf-engineer`), otherwise omitted.
- `objective` — one-line summary of the ask.
- `outcome`: an open word while the dispatch runs (`pa dispatch open` writes `dispatched`; `null` also reads as open). On return, a short outcome that begins with a closed word (`done`, `success`, `failed`, `partial`, `abandoned`, ...).
- `completedAt`: UTC ISO string of when the dispatch closed. Set it on return; it closes the record whatever `outcome` says.

Extra fields are fine. Never remove the minimum set.

## Update-at-dispatch-time

The spawn-log is the operator's LIVE view of what is in flight. Updating it at session end defeats the purpose — the operator can't see current state.

**When you dispatch:** write the entry immediately, before the dispatch fires, with `pa dispatch open --role <role> --objective "..." --project <id> --work-item <id> --slug <slug>` (plus `--rcf-stage` and `--rcf-hand` for RCF work, and `--initiative`, `--workstream`, `--stage` when they apply). It mints the next `d-YYYY-MM-DD-NNN` for today's UTC date, writes the entry with `outcome: "dispatched"` into every container the file has (a new file starts as `{ "version": "1.0", "spawns": [] }`), adds a `running` board item holding the id, and prints the id and the label. The write is atomic and runs under a short lock beside the file (`spawn-log.json.lock`), so two writers at the same moment lose nothing; a lock is broken on its own only when its holder is on this host and its pid is no longer running, and every commit checks the lock is still its own first. Any other held lock blocks: after 10 s the verb exits 2, writes nothing and names the holder and `pa dispatch unlock`. The design, its guarantees and each crash point are in [spawn-log-lock.md](spawn-log-lock.md). The lock is supported on one host with the estate on a local filesystem; a spawn-log folder shared across hosts (a network share, or one folder written from two machines) is unsupported, because two hosts with one hostname, or a share whose directory listing hides a just-completed claim, can each break a live lock automatically and lose a record (that page explains both); if such a folder is ever needed, the route is a lock-free write path, not this lock.

**At a checkpoint:** `pa dispatch stage --id <id> --line "..." [--stage "..."]` appends `{ at, line, stage }` to the entry's `checkpoints`, sets its `stage` (a string, or `"<n>/<m> <label>"` for `{ n, m, label }`) and refreshes the board item. On a closed dispatch it is refused (exit 1) and writes nothing.

**When the dispatch returns:** `pa dispatch close --id <id> [--outcome "<word ...>"]` sets the entry's `completedAt` and `outcome` (it must begin with a closed word; `done` by default) and moves the board item to `done`. A second close of a closed dispatch is refused (exit 1) and writes nothing. Don't wait for session end.

**A held lock:** `pa dispatch unlock [--force]` prints the lock's holder and removes the lock when that holder is provably dead; any other holder is refused (exit 2) unless `--force` is given. Use `--force` only once the holder's process is gone or stuck, never while it is still running: a holder still running when its lock is forced out can lose a record, or move a newer holder's lock, until its own release is done ([spawn-log-lock.md](spawn-log-lock.md) names that window). `pa board check` names a lock held for more than a minute.

**A failed board patch** never undoes the spawn-log write: the entry stays, the verb exits 2 and names the board error, and `pa board check` fails until the board is fixed. `pa dispatch list [--json]` prints the open dispatches by the rule below and writes nothing.

## Open or closed: the shared rule

One rule decides whether a dispatch is open, and every reader uses it (`isOpenDispatch` in the core reader), so the Status page, the Delegations page and `pa board check` always agree. On the latest record of an id:

1. A non-empty `completedAt` (a string or a number) closes it. `null` or an empty string counts as absent.
2. Otherwise take each of `status` and `outcome` that is a non-empty string. The record is open when there is none, or when every one of them begins with an open word: `dispatched`, `launched`, `in-flight`, `in-progress`, `running`, `gate`, `paused`. Any other word closes it.

"Begins with" ignores case and means the word itself, or the word followed by a character that is not a letter: `launched local Workflow` and `in-flight-at-wrap` are open, `done: shipped` is closed, `gated` is not `gate`.

The package also knows a closed set: `closed`, `done`, `returned`, `complete`, `completed`, `partial`, `success`, `failed`, `abandoned`, `superseded`, `killed`, `aborted`, `cancelled`, `archived`, `dropped`, `unknown-outcome`, `halted`, `stopped`, `interrupted`, `escalate`, `hard-stop`. A word on a record launched in the last 48 hours, with no `completedAt`, that begins with neither set is closed and reported by `pa board check` as an `unrecognised status` warning (exit 0), so an instance can tidy its words without the board breaking.

**The window.** The launch time is `timestamp`, else `ts`, else `dispatchedAt`. An open record launched under 24 hours ago is live; one launched 24 to 48 hours ago is stale (a missed close: close it or check it); an older or untimed open record is in flight nowhere: the board check and the Status page leave it out, and the Delegations page lists it as open but counts it under Other. Every surface's in-flight count is live plus stale.

## Reading the tail

On session boot, read the tail (last 5 entries) to see recent dispatches. If any are still open by the rule above, check whether they returned via task-notification or if they need chasing. `pa board check` lists every open dispatch, live and stale, and the session-start hook prints the same list.

Older entries stay in place — the spawn-log is a chronological record. Don't prune it in the same session; the Librarian may sweep it at distillation.

## Related

- CLAUDE.md.template Bucket 2 rules 5 and 10.
- `status-board.md`: the daily board and `pa board check`.
- CLAUDE.md.template Bucket 3 (delegation) — the write-side of dispatch.
