feat(queue): queue as data A2, migration, render and dispatch (#1508)

Filbert approved round 1 (f167b85e). Manifest 782bcb62, 21 files, plus
the QUEUE.md markers and the TOOLS.md section. Lead decision 35.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 20:14:09 -05:00
co-authored by Claude Opus 5.5
parent c9539baa0f
commit 6ca116b7ba
32 changed files with 4886 additions and 103 deletions
@@ -0,0 +1,21 @@
b18afc13db533b5cbfac8701234b25dde87c9c1b4fda970e738faf87b7a6d324 packages/queue/README.md
a1e72713398a3f706e4beb5ad012798ab6a55f2f76c2f3144db94011a45a3231 packages/queue/src/cli.mjs
962d0e6548121933030980b937f55ca5913ee3859ff2b8888095bd273dbf561e packages/queue/src/io.mjs
05682738482a56001bac168168a6aeb7e624f8ed1661a6c6592274f13da38c89 packages/queue/src/lock.mjs
b87756a89c3bdeb03bddd1b1e51706ae93975b01f008237076ffe34557a71de3 packages/queue/src/queue.mjs
b7708293363d33093a1106c2346c26ce5d48e052342abbec17c59ee058a4fb05 packages/queue/src/store.mjs
70a11be54d8bddd3db0fd52555a1bdf481efef0cae671ee8e1aab546e3ddd36a packages/queue/tests/data.test.mjs
b1c90f99eb2b5005ebc461dab5793164b80d7e846573f788606040ef88f5819e packages/queue/tests/helpers.mjs
e7c9b23b6aa28321754c1c649f8bd5ee2c97bfea0d4e180ae699a4a1f5c60c76 packages/queue/tests/lock.test.mjs
42120f0009815bedd3887b10e3630a694711825e4dee29a475733dbd212a49eb packages/queue/tests/store.test.mjs
f79195520a1108ce5748ad6833eb03d2bb3ef97d74d83807ee1079e461ebb1f4 packages/queue/tests/write.test.mjs
86c2a4f806e99fd09baf623dc7b3c95c1a630d147fe89c4fe5b93793fa4c5f50 scripts/mosaic
b6aec15a75305bb48bca20a665bd919111a0071ffd7a1cc3631df2beaaac5a1b scripts/test-queue.sh
dc32c0e0a865fe6b3623a808c4e9d8e8b284a3e74c36b3519554ea02d3a3ee5f packages/queue/tests/dispatch.test.mjs
0d83ab1e73663de0f3da719078e7d756b8ec8f828ea63c2ce446bf21fb27c754 packages/queue/tests/migration.test.mjs
3306b486be165b88c554ba13283cab4c79a5b67fbe3c2b6bfe72f987e1eddf07 packages/queue/tests/fixtures/genesis-render.md
fb43d5855726e517f0eaacd626debb110e8ea6c948c97b35ad8971ec0885d151 packages/queue/tests/fixtures/mosaic-pre-a2.sh
8f1927f3f11f260e71e7235a69efcbf10c8fd62bf263ddb89c3029f5e61833b7 packages/queue/tests/fixtures/queue-marked.md
012ddce99d03932512d79b6ae7ba74786e050469e800cfb00fda683a2dfe42a8 agents/darkwing/work/queue-migration-map.md
cdc49c447504f6c9f1c40da9ec0d89bf4d21c5e2a0730b1f12d1f20fbea63edb agents/darkwing/work/queue-a2/map-check.mjs
b2a738f0da52783dc8e8a7c6033ce62582582e587eb6e5b6bde988ea4b406f3e agents/darkwing/work/queue-a2/carry-forward.md
+202
View File
@@ -0,0 +1,202 @@
# Queue A2 (#1508), candidate for review
Darkwing, 2026-09-27. A2 is migration, render and dispatch (lead decision
20), plus every item Sage carried forward from A1's review (decision 26:
N5, N8, N10, N11, N12, P1, P2, P3). Base is HEAD c9539baa; A1 is 34a72af9.
Filbert reviews. Sage commits, then installs the hook and runs genesis.
Nothing is committed, staged or pushed.
## Files
`build.patch` (sha256 `dc0be7ce74aaaaaf43deebe68af439d1b2b80c77aad6538420e929de35e2f21f`) changes 13 files and adds 8.
`build-manifest.sha256` (sha256 `782bcb62e555659333a35888d418d986da63bdf522f38cafa447db7ddd0074b7`) pins all 21 after the patch.
In a fresh clone at c9539baa the patch applies and the result matches
the manifest 21/21, file modes included. `git apply` warns about one
blank line at the end of `fixtures/genesis-render.md`. It belongs there:
the render ends with one, and the golden has to match byte for byte.
- `packages/queue/src/`: `queue.mjs` (N8, N10, N11, P2), `store.mjs` (N12,
N11's shared check, P1's helper, P3's caller), `lock.mjs` (P1, P3),
`io.mjs` (N5), `cli.mjs` (usage comment).
- `scripts/mosaic`: `queue` execs `packages/queue/src/cli.mjs` with the
remaining arguments. Every other call reaches the seat CLI as before.
- `scripts/test-queue.sh`: syntax checks for `scripts/mosaic` and the new
fixture; `scripts/mosaic queue help` always; after genesis, `verify` and
`render --check` on the live queue through `scripts/mosaic queue`.
- `packages/queue/README.md`: the dispatch, N5, N7, N8, N10, N12, P2, the
map and `map-check.mjs`, two new test files.
- Tests: 107 in A1, 122 now. New: `dispatch.test.mjs` (3),
`migration.test.mjs` (3), and 9 more in data, lock, store and write.
Fixtures: `mosaic-pre-a2.sh` (the script before A2), `queue-marked.md`
(QUEUE.md with the markers), `genesis-render.md` (the golden render).
- `agents/darkwing/work/queue-migration-map.md`: the genesis input.
- `agents/darkwing/work/queue-a2/map-check.mjs`: the drift check.
- `agents/darkwing/work/queue-a2/carry-forward.md`: the item list Sage
confirmed (773dbd75), with the r1 section added after it.
Outside the patch, for Sage to apply (condition 2 keeps them out of A2):
- `queue-md.patch` (sha256 `2ca8f689e1dcda5bb30e9af4c3f867242d5239d72e13001369c7b7aca7956402`): the two markers, a header that points
seats at `scripts/mosaic queue next`, and a line freezing the old log of
table changes. It applies to HEAD.
- `tools-md.patch` (sha256 `c53aea1f6e871e17d04335ef60250adcdbe64f6466d9ff641042c00094d1c3a8`): a "Work queue" section in
`docs/TOOLS.md`. It applies to HEAD. Optional; the README already has
the detail.
## The five conditions
1. **Nothing ran against the canonical `.git`.** Tests use scratch
repositories under the temp directory. The dry run below used
`/tmp/qa2-dry`. Checked after all runs: no `mosaic-queue*` file in
`.git/`, `.git/hooks/pre-commit` absent, `core.hooksPath` unset in every
scope, nothing staged.
2. **No QUEUE.md, AGENTS.md or TOOLS.md edits.** The two proposals are
patch files. `git status` shows none of the three modified.
3. **`scripts/test-queue.sh` is green at a HEAD with no `queue.json`:**
24/24, the live checks skipped. After the dry-run genesis it ran 26/26, with `verify` and
`render --check` on the live queue.
4. **H is recorded before the canary.** `queue-commit.sh` is unchanged
from A1, and its test "F1: H is recorded before the canary, so HEAD
moving during the canary makes update-ref fail" passes.
5. **The fault layer is reachable only from tests.** N5 narrows this
further: tmpfs was the one fault-free path open to the CLI, and now
only a layer with `allowTmpfs: true` gets it. The test asserts
`realIo` has no such key and that `"yes"` doesn't count.
## Carried-forward items
| Item | Change | Test |
|---|---|---|
| N8 | `piece`, `gate`, `note`, move `reason`, brief anchor and `blockedReason` refuse `\` and `<`, at the CLI and in replay | "text the table shows refuses \ and <, everywhere it enters"; "every accepted text renders to nine cells on every row" (GFM's cell rule, no `marked` import) |
| N11 | Replay holds every log entry, round op and claim op to `CALLER_OP_RE` and refuses `.outcome`, genesis and `accept-history` included. `LOG_OP_RE` stays for Piece D's derived ids; no verb derives one yet | "replay holds every op id to the caller's rule" |
| N10 | `set issues` moves `closes` only if it equalled the old issues; a narrowed `closes` keeps its intersection, and the receipt says `(kept narrowed)` | "set issues keeps a logged narrowing of closes" |
| P2 | Each round records `issue`; `review` is `{rounds}` only. A later round keeps the last round's issue | "the row schema refuses a round with a null issue, and the A1 review shape", and A1's R2 tests updated |
| P1 | Both gate paths in `acquire` release through `releaseOrWarn`; a failed release is a message naming the lock, not a stack trace | "a release that fails on a gate path is reported, never a stack trace" |
| P3 | `lock.unlock` returns `{result, warning}`; nothing splits on newlines | "unlock keeps a multi-line lock record on stdout" |
| N5 | tmpfs left `FS_TYPES`; only `allowTmpfs === true` admits it | "tmpfs passes only a test layer that allows it" |
| N12 | `--by` still wins; a different non-empty `MOSAIC_AGENT_NAME` adds a stderr warning on success and on refusal. Nothing is logged | "--by that differs from MOSAIC_AGENT_NAME warns on stderr and logs nothing more" |
The rest, as `carry-forward.md` records and decision 26 confirmed: N7 is a
README line (the leftover `.git/mosaic-queue.lock.<pid>.<hex>.tmp` is
removed by hand). N15, N16, N13-a and ext2/ext3 are won't-do.
## Migration
The map (`queue-migration-map.md`) is built from QUEUE.md blob c8e3d34e,
which HEAD has. `node agents/darkwing/work/queue-a2/map-check.mjs` prints
`ok: QUEUE.md matches the map (blob c8e3d34e)`. Run it again just before
genesis. It exits 1 and lists the rows if QUEUE.md moved.
map-check did its job once already. HEAD moved from 8dba3ff7 to c9539baa
while I worked, and it reported row 5 (the CHAT-03 brief pinned, lead
decision 33). I rebuilt row 5's note, `queue-md.patch`, the marked fixture
and the golden render against the new blob. No other row changed.
The map's "Choices Sage should check" has eight items. None blocks review.
The ones that change what genesis writes: row 7 stays a live row (decision
27), row 10's owner stays `coordinator` with `queue assign 10 sage`
recommended as the first op, rows 12 and 13 have Sage as gate owner, row 16
stays `waiting-on-jason` unless #1510 is closed, and rows 9 to 12 close
nothing so row 13 closes #1508. Rows 9 to 13 have no `after`: decision 29
took the wait on row 6 off them, and `after: 9 done` on row 10 would wait
on a gate that needs row 10.
Dry run in a shared clone (`/tmp/qa2-dry`), the candidate plus
`queue-md.patch` and `tools-md.patch` committed on top of c9539baa. I
reran it from scratch after the rebase:
0. `map-check.mjs`: `ok: QUEUE.md matches the map (blob c8e3d34e)`.
1. `scripts/test-queue.sh`: 24/24, live checks skipped.
2. `scripts/queue-commit.sh --install-hook --by sage`: installed; canary
passed.
3. `scripts/mosaic queue genesis --root /tmp/qa2-dry --branch a2-dry --map
… --op genesis-2026-09-27 --by sage`: `ok genesis-2026-09-27 rev 0
genesis 30 rows`.
4. `scripts/queue-commit.sh --genesis`: committed.
5. `verify --current`: briefs match HEAD. `render --check`: current.
`legacyView` equals the marked body byte for byte, and `mapBlob` is the
map's blob.
6. `next`: darkwing resumes 9, sage resumes 7, dewey resumes 5, filbert
and rocko have nothing.
7. `scripts/test-queue.sh`: 26/26 with the live checks.
8. `queue assign 10 sage --op assign-10-dry --by sage`: `ok … rev 1 row 10 owner:
coordinator→sage`.
The dry clone's hook is `.git/hooks/pre-commit`, mode 0755, blob
abdf14e7. `core.hooksPath` is unset there too.
## Choices I made
- **N8 refuses instead of escaping.** An escape has to be right for every
Markdown renderer that reads QUEUE.md; a refusal doesn't. No line in
today's table has either character.
- **P2 drops `review.issue`.** The last round's issue is the kept one, so
a second copy could only disagree. Piece D reads `rounds[].issue`.
- **N10's edge.** A `closes` narrowed to nothing stays empty whatever the
new issues are. `set closes` with a reason is the way to widen it again.
- **N12 warns on a refusal too,** so a seat that mistyped `--by` sees it on
the error it was reading anyway.
- **`scripts/mosaic queue` uses `exec`,** so exit codes and signals are the
queue CLI's own. Any first argument other than exactly `queue` goes to
the seat CLI, as before; the dispatch test compares argv, cwd,
environment and stdin against the pre-A2 script.
## Mutations
Each mutation ran alone in a shared clone of the candidate, with
`node --test packages/queue/tests/`. A killed mutation fails at least one
test.
46 mutations. Each one below failed at least one test; the number is
how many.
| Item | Mutations |
|---|---|
| N8 | backslash 2, lt 2, noteVerb 1, rowNote 2, anchor 1, reason 1 |
| N11 | entryShape 1, outcome 2, genesisOnly 1, roundOp 1, claimOp 1 |
| N10 | always 2, noIntersect 1, receipt 1 |
| P2 | keptFirst 1, noCheck 1, reviewIssue 10 |
| P1 | gateErr 1, gatePresent 1, gatePresentMsg 1, withLock 1 |
| P3 | joined 2, storeSplit 1 |
| N5 | inTypes 1, truthy 1, anyType 1 |
| N12 | noWarnOk 1, noWarnErr 1, envWins 1, emptyEnv 1 |
| Dispatch | noShift 1, unsetArg 1, prefix 1, noExec 1 |
| Golden render | mapGate 1, mapState 1 |
| map-check | fixed26 1, owner 1, issues 1, lineDiff 1, parkedPiece 1 |
Five of them survived the first pass. Each now has a test that kills it:
- N11 roundOp and claimOp (replay stops checking a review round's op or a
claim's op). The N11 test now builds a claimed row and a reviewed row and
refuses an op of 73 characters and one ending `.outcome` in each.
- P1 withLock (the post-op release goes back to the throwing `release`).
New write test: the lock's unlink fails with EACCES after the op, and the
receipt and a refusal both carry "cannot release the queue lock (EACCES)".
- N12 emptyEnv (an empty `MOSAIC_AGENT_NAME` warns). The N12 test now runs
with it empty and expects no stderr.
- Dispatch noExec (`node` without `exec`, so a successful queue call falls
through to the seat CLI). The dispatch test now runs a queue call that
exits 0 and checks that only the queue CLI ran.
The map and golden-render mutations ran again after the rebase and were
still killed.
## Suites
All nine green at the final candidate: config 24, task 90, foundation
44, conductor 17, release 14, auth 15, discord 64, extension-package 18,
queue 24 (no `queue.json` at HEAD). `node --test packages/queue/tests/`:
122/122.
## After approval (Sage)
1. Check the canonical tree against `build-manifest.sha256`, then commit
the 21 files by path.
2. Apply `queue-md.patch` (and `tools-md.patch` if wanted) and commit.
3. `node agents/darkwing/work/queue-a2/map-check.mjs` must print `ok`.
4. `scripts/queue-commit.sh --install-hook --by sage`.
5. `scripts/mosaic queue genesis --root /mnt/storage/src/mosaic-stack
--branch refactor --map agents/darkwing/work/queue-migration-map.md
--op <id> --by sage`.
6. `scripts/queue-commit.sh --genesis -m MSG`.
7. `scripts/test-queue.sh` runs the live checks from here on.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
# Queue A2 (#1508): items carried from A1 review
Darkwing, 2026-09-26. This file records Sage's ruling on A1 r1 so it isn't
lost before A2 starts. A2's brief lists the two required items. A2's build
note repeats each disposition. Note numbers are Filbert's, from
`agents/filbert/work/queue-a1-review-2026-09-26.md` (sha256 6933b885).
## Required in A2, with tests
Both change what replay accepts or what the table shows, so they land
before genesis. Genesis follows A2.
- **N8. `cell()` escapes `|` but not `\`.** Piece text `a\| done | x`
renders so that `marked` splits it into an extra cell. Raw HTML passes
through too.
- The change: refuse `\` and `<` in rendered text fields at the CLI and
in replay. A refusal holds up better than an escape we would have to
get right for every Markdown renderer. None of the 34 table lines in
today's QUEUE.md contains either character, so the migration loses
nothing.
- The test: every text field with `\`, `<` and `a\| done | x` is
refused. A render of the allowed characters splits, by GFM's cell
rule, into the same number of cells on every row. `marked` is only a
transitive dependency in this repo, so the test doesn't import it.
- The mutation: allow `\`, and the cell-count test must fail.
- **N11. Replay is looser than the CLI on op ids.** `LOG_OP_RE` allows 80
characters for any entry, `accept-history` may end in `.outcome`, and
the genesis op isn't checked.
- The change: replay applies `CALLER_OP_RE` to every op a caller chose.
It allows the longer form only for the op ids the CLI derives. It
refuses `.outcome` on `accept-history` and pattern-checks the genesis
op.
- The test: a hand-built file with each of the three refused shapes
fails replay, and every op id the CLI writes still replays.
- The mutations: restore each looser check in turn.
## Dispositions of the other notes
| Note | Disposition | Reason |
|---|---|---|
| N5 tmpfs accepted outside tests; `0xef53` also matches ext2 and ext3 | A2: tmpfs becomes a test-only option, like the other fault options. ext2 and ext3: won't do | `statfs` can't tell ext2, ext3 and ext4 apart. The README names ext4, and the canonical checkout is ext4. |
| N7 temp files from killed acquires stay in `.git/` | A2: README line only | The files are small, carry the dead pid in their name, and never block a lock. Removing them safely needs the same liveness check `unlock` has, which isn't worth it for the space involved. |
| N10 `set issues` resets `closes`, undoing a logged narrowing | A2: fix with a test | It changes replayed state, so it lands before genesis. New rule: `closes` becomes the new issues only if it equalled the old issues. Otherwise it keeps its intersection with the new issues, and the log entry says so. |
| N12 `--by` silently overrides `MOSAIC_AGENT_NAME` | A2: stderr warning, no log field | Both values are self-asserted (J2), so a logged mismatch proves nothing a seat can't avoid. A warning catches the honest mistake, a typo or the wrong seat's shell. |
| N15 `--install-hook --by` is self-asserted | Won't do | J2. It's protocol: Sage runs the install at bootstrap. A check on a claimed name adds nothing. |
| N16 the hook refuses the first commit on an unborn HEAD | Won't do | The hook is installed only in the canonical checkout, which has history. It fails closed. |
Sage confirmed this file as written (773dbd75) on 2026-09-26. N5, N10 and
N12 stay in A2. N10 has to land before genesis because it changes replayed
state.
## From Filbert's r1 approval
Filbert approved A1 r1 and N13 on 2026-09-26 (review
`agents/filbert/work/queue-a1-review-r1-2026-09-26.md`, sha256 e464be6c).
He listed these as non-blocking and suitable for A2. The dispositions
below are my proposal, and Sage rules on them.
| Note | Proposed disposition | Reason |
|---|---|---|
| P1 `acquire` calls `release()` unguarded on both gate paths | A2: fix with a test | If release throws (EACCES on `.git`), the CLI prints a stack trace and doesn't say the lock stayed. The fix guards it the way `withLock` does and names the lock left behind. |
| P2 `--issue` on a later round overwrites `review.issue`; rounds don't record their own issue | A2: each round records its issue, with a test | Piece D posts per round, and the round's own issue is the evidence of where it posted. It changes the round schema and replayed state, so it lands before genesis, like N10. |
| P3 `unlock` splits its result on newlines, so a hand-formatted record spills onto stderr | A2: fix with a test | `lock.mjs`'s `unlock` returns the result and the warning separately, so nothing is split on text. |
| N13-a `n13-check.sh` copies the suites from the canonical tree, not from the pinned patch | Won't do | N13 is approved on `n13.patch` and the two suite hashes, and Filbert applied the patch himself. The check script is evidence, not shipped code. |
N13-b is Sage's: check the canonical working tree against both pins
before committing from it.
@@ -0,0 +1,80 @@
#!/usr/bin/env node
// Usage: node agents/darkwing/work/queue-a2/map-check.mjs [QUEUE.md] [MAP]
//
// Run before genesis. The map names the QUEUE.md blob it was built from.
// This lists every table line that differs from that blob, then every row
// whose piece, owner or issues no longer match the map. Reads only; the
// one git call is `cat-file`. Exit 0 no drift, 1 drift, 4 usage.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const top = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
const { parseMigrationMap } = await import(join(top, "packages/queue/src/queue.mjs"));
// Table lines by id: `| N | ...` rows, then the parked table's items
// numbered on from the highest row, as the map numbers them.
export function tableLines(text) {
const out = new Map();
const items = [];
let parked = false;
for (const line of text.split("\n")) {
if (line.startsWith("## ")) parked = line.startsWith("## Parked");
const m = /^\| (\d+) \|/.exec(line);
if (m && !parked) out.set(Number(m[1]), line);
else if (parked && line.startsWith("| ") && !line.startsWith("| Item ") && !line.startsWith("|---")) items.push(line);
}
let next = Math.max(0, ...out.keys()) + 1;
for (const line of items) out.set(next++, line);
return out;
}
const cells = (line) => line.slice(2, -2).split(" | ");
export function check(queueText, mapText, oldText) {
const drift = [];
const now = tableLines(queueText);
const then = tableLines(oldText);
for (const id of new Set([...now.keys(), ...then.keys()])) {
if (now.get(id) !== then.get(id)) drift.push(`row ${id}: ${!then.has(id) ? "added" : !now.has(id) ? "removed" : "changed"} since the map's QUEUE.md blob`);
}
const map = parseMigrationMap(mapText);
const byId = new Map(map.rows.map((r) => [r.id, r]));
for (const [id, line] of now) {
const r = byId.get(id);
if (!r) { drift.push(`row ${id}: in QUEUE.md, not in the map`); continue; }
const c = cells(line);
if (!/^\d+$/.test(c[0])) {
if (c[0] !== r.piece) drift.push(`row ${id}: parked item ${JSON.stringify(c[0])} is not the map's piece`);
continue;
}
if (c[1] !== r.piece) drift.push(`row ${id}: piece differs from the map`);
const owner = /^[a-z][a-z0-9-]*/.exec(c[2])?.[0];
if (owner !== r.owner) drift.push(`row ${id}: owner ${owner} in QUEUE.md, ${r.owner} in the map`);
const issues = [...c[3].matchAll(/#(\d+)/g)].map((x) => Number(x[1]));
if (issues.join() !== r.issues.join()) drift.push(`row ${id}: issues ${issues.join(",") || "none"} in QUEUE.md, ${r.issues.join(",") || "none"} in the map`);
}
for (const id of byId.keys()) if (!now.has(id)) drift.push(`row ${id}: in the map, not in QUEUE.md`);
return drift;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
if (args.length > 2) {
console.error("usage: map-check.mjs [QUEUE.md] [MAP]");
process.exit(4);
}
const queueText = readFileSync(args[0] ?? join(top, "docs/plans/QUEUE.md"), "utf8");
const mapText = readFileSync(args[1] ?? join(top, "agents/darkwing/work/queue-migration-map.md"), "utf8");
const blob = /QUEUE\.md` blob `([0-9a-f]{40})`/.exec(mapText)?.[1];
if (!blob) {
console.error("the map names no QUEUE.md blob");
process.exit(1);
}
const oldText = execFileSync("git", ["-C", top, "cat-file", "blob", blob]).toString("utf8");
const drift = check(queueText, mapText, oldText);
for (const d of drift) console.log(d);
console.log(drift.length ? `${drift.length} differences; update the map before genesis` : `ok: QUEUE.md matches the map (blob ${blob.slice(0, 8)})`);
process.exit(drift.length ? 1 : 0);
}
@@ -0,0 +1,65 @@
diff --git a/docs/plans/QUEUE.md b/docs/plans/QUEUE.md
index c8e3d34..dd155c6 100644
--- a/docs/plans/QUEUE.md
+++ b/docs/plans/QUEUE.md
@@ -1,29 +1,32 @@
# QUEUE — the one task list
-Read this first. One row per piece. Nobody needs to read the prose plans to
-know what is next; the Brief column says which section to open only when you
-are the owner of that row.
+Read this first. One row per piece. The table between the markers is
+rendered from `docs/plans/queue.json`, and `scripts/mosaic queue` is its only
+writer. Don't edit the table by hand: `queue verify` and the commit hook
+refuse a table that isn't the render. `packages/queue/README.md` has the verbs.
How to find your next thing:
-- **Jason**: the first row whose State starts with `Jason:`.
-- **A seat**: the first row where Owner is you and State is `in progress`,
- `in review` or `briefed`. If there is none, you have nothing; say so on the
- board and stop.
-- **Sage, project lead** (Jason's ruling 2026-09-26; Darkwing before that): update this table at every gate, before
- anything else is written. CURRENT.md is the narrative log; this table wins
+- **A seat**: run `scripts/mosaic queue next`. It names the row to resume,
+ review or start, or says there is nothing; if nothing, say so on the board
+ and stop.
+- **Jason**: rows in state `waiting-on-jason`, and parked rows to reopen.
+- **Sage, project lead** (Jason's ruling 2026-09-26): changes the queue with
+ `scripts/mosaic queue` and commits `queue.json` with QUEUE.md through
+ `scripts/queue-commit.sh`. CURRENT.md is the narrative log; the queue wins
if they disagree.
-States: `queued` (no brief yet) → `briefed` (brief written, not started) →
-`in progress` → `in review` → `Jason: <what he must do>` → `done`. `parked`
-means not before the rows above it and not without Jason's say. `required`
-means it cannot be parked or reordered below `queued` rows; only Jason moves it.
+States: `queued` (brief exists, not accepted) → `briefed` → `in-progress` →
+`in-review` → `waiting-on-jason` → `done`. `blocked` returns to the state it
+left. `parked` rows wait for Jason to reopen them. A `required` row can't be
+parked, and only Jason clears the flag. `after` names the rows a piece waits
+for.
-Brief locations: "plan page" is `docs/plans/2026-09-12_control-board-mvp.md`.
Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
## Pieces (in order)
+<!-- mosaic-queue:begin -->
Lead: Sage from 2026-09-26 (Jason's ruling); Darkwing is a collaborating seat.
Jason is preparing the target for the next phase; until it arrives, the
priority below stands.
@@ -79,8 +82,13 @@ Gate F or when blocked."
| Console features outside the refined session-chat brief, including Fresh creation and model switching | Deferred by WEBUI Q1; required history/control/stop now belong to row 5, not this parked item | `2026-09-13_webui-session-chat.md` |
| Open gaps from the MVP work | Fixed only when a gate needs them | DEFERRED.md, "Open" |
+<!-- mosaic-queue:end -->
+
## Log of table changes
+Frozen at genesis. Since then the log in `docs/plans/queue.json` records every
+change; the entries below are history.
+
- 2026-09-13 — created; rows 1 to 8 taken from CURRENT.md, DEFERRED.md and the plan page.
- 2026-09-13 — rows 9 to 13 added (#1508): the process itself becomes data with one writer and ledger checks. Jason: "I want this iron-clad." Required, not parked; rule: these rows cannot be moved to parked, only to done.
@@ -0,0 +1,29 @@
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 5f993af0..02e0de9e 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -147,6 +147,24 @@ npm-global `mosaic` CLI; run by path. Exit codes: the launch script's own
once it runs; before that 1 could not start, 2 invalid config or seat, 4
usage. Details and the record's fields: `packages/seat/README.md`.
+## Work queue (`scripts/mosaic queue`)
+
+```bash
+scripts/mosaic queue list | show ID | next [SEAT]
+scripts/mosaic queue add|move|release|assign|note|set ... --op ID [--by NAME]
+scripts/mosaic queue verify [--current] | render [--check] | sync | unlock [--check-gate]
+scripts/queue-commit.sh -m MSG
+```
+
+`docs/plans/queue.json` holds the rows and an append-only log; the table in
+`docs/plans/QUEUE.md` between the `mosaic-queue` markers is rendered from it.
+Canonical checkout only. Every change needs an `--op ID` chosen before the
+first attempt and reused on retry; only an op whose `ok <op> rev N` receipt
+printed is done. The lead commits queue changes with `scripts/queue-commit.sh`;
+the pre-commit guard refuses any other commit that stages the two queue files.
+Exit codes: `0` ok · `1` failed · `2` invalid or refused · `3` uncertain,
+retry the same op · `4` usage. Details: `packages/queue/README.md`.
+
## Discord connector (`scripts/discord.sh`)
```bash