Files
stack/agents/darkwing/work/queue-a2/build.patch
T
jason.woltjeandClaude Opus 5.5 6ca116b7ba 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]>
2026-09-26 20:14:09 -05:00

2520 lines
161 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/agents/darkwing/work/queue-a2/carry-forward.md b/agents/darkwing/work/queue-a2/carry-forward.md
new file mode 100644
index 0000000000000000000000000000000000000000..8b89eaf2304a41a0fbd6ee869c5a7bfea5559fae
--- /dev/null
+++ b/agents/darkwing/work/queue-a2/carry-forward.md
@@ -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.
diff --git a/agents/darkwing/work/queue-a2/map-check.mjs b/agents/darkwing/work/queue-a2/map-check.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..5826c9294595671b10717531b458ab748a2dca99
--- /dev/null
+++ b/agents/darkwing/work/queue-a2/map-check.mjs
@@ -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);
+}
diff --git a/agents/darkwing/work/queue-migration-map.md b/agents/darkwing/work/queue-migration-map.md
new file mode 100644
index 0000000000000000000000000000000000000000..21fc374784399b418ba6625eb0cc3e98bf445257
--- /dev/null
+++ b/agents/darkwing/work/queue-migration-map.md
@@ -0,0 +1,878 @@
+# Queue migration map (#1508, A2)
+
+Darkwing, 2026-09-27. This is the reviewed input to `queue genesis --map
+agents/darkwing/work/queue-migration-map.md`. Genesis reads the one
+`json queue-map` block at the end. Everything above it is for review.
+
+Built from `docs/plans/QUEUE.md` blob `c8e3d34e6efcb98070ebf226157ac027811ffa54`
+(HEAD c9539baa). If a row in QUEUE.md changes before genesis, the map has to
+change with it. `queue-a2/map-check.mjs` lists the table lines that differ
+from that blob and any piece or owner that no longer matches.
+
+## What genesis keeps
+
+- Rows 1 to 25 keep their ids. The five parked items become rows 26 to 30,
+ in table order, with state `parked`, owner `unassigned` and no issue
+ (plan Q9). `highWater` is 30 and nothing is retired.
+- The text between the QUEUE.md markers goes into genesis as `legacyView`,
+ byte for byte. That covers every row, the parked table, the start message
+ and the priority paragraph. A shortened note loses nothing, because the
+ full State text is there.
+- The State column becomes `state` plus `note`. Piece and Gate are copied
+ verbatim. Every text passes A2's rule: no `\` or `<`, piece and gate at
+ most 300 characters, notes at most 500.
+- Each row has one brief, `{path, anchor}`, and genesis pins the blob. Every
+ anchor occurs once as a heading in HEAD. Done and parked rows keep their
+ briefs; `verify --current` skips them.
+- `createdAt` comes from the log of table changes. Parked items 1, 2, 3 and 5
+ date from 7c8e530a, the commit that created QUEUE.md (2026-09-13 UTC). Item
+ 4 has no evidence before 2026-09-26, so it is `unknown`.
+- `requiredSince` for rows 9 to 13 is 2026-09-13, the log line that made them
+ required.
+- `reviewers` holds seats under `agents/` only. A lane named for a fleet seat
+ (`rev-code-02`, `orch-01`) stays in the note.
+- Genesis gives a claim to each `in-progress`, `in-review` and
+ `waiting-on-jason` row: 5, 7, 9, 11 and 16.
+
+## Choices Sage should check
+
+1. **Row 7 stays a row.** Plan Q9 retired it, but lead decision 27 made it
+ Sage's weekly ledger run, with `packages/ledger/README.md` as its brief.
+ The map keeps it as `in-progress` with Sage's claim, so `queue next sage`
+ resumes it and Sage records the weekly number with `queue note 7`.
+ Retiring it would instead need `retired: [7]` and a pointer line under
+ the markers.
+2. **Row 10 keeps owner `coordinator`.** No seat can act as `coordinator`,
+ so only Jason or Sage can move the row, and its work edits AGENTS.md,
+ which is Sage's. I recommend `queue assign 10 sage` as the first
+ operation after genesis, so the change is logged instead of hidden in
+ the migration.
+3. **Rows 10, 12 and 13 have no `after`.** QUEUE.md says "after row 9", but
+ row 9 stays open until Gate G, and Gate G also verifies row 10, so `after:
+ 9 done` would hold row 10 behind a gate that needs it. A's code is in place
+ once genesis is committed, and each note says so.
+4. **Row 11 is `waiting-on-jason`.** QUEUE.md says the `queue add` refusal
+ lands with A2. It landed in A1: `add` runs the same brief check genesis
+ does. Only the gate is left, and the gate is Jason's.
+5. **Gate owners.** Jason holds every gate except rows 12 and 13, which the
+ map gives to Sage. Both are checks the lead can see: a review round with
+ no new file under `reviews/`, and the ledger run Sage does each Monday.
+ With Sage as gate owner, J5 lets `in-review` → `done` happen without
+ Jason.
+6. **Row 16 stays open, as `waiting-on-jason`.** Its State doesn't say done.
+ Lead decision 29's "rows 14 to 25 are done" sits in the #1509 paragraph,
+ and row 16 is #1510. If #1510 is closed, change the row to `done` before
+ the map is committed.
+7. **`closes`.** Rows 9 to 12 close nothing and row 13 closes #1508, so piece
+ E won't report #1508 open under a done row 9. This is J6's narrowing, and
+ this line is its reason. Every other row closes its issues.
+8. **Row 5's note** matches QUEUE.md at c9539baa: the CHAT-03 brief is
+ pinned, and Sage names the source author after A2 lands. When the row
+ changes before genesis, the note changes with it.
+
+## Row by row
+
+| # | State | Owner; reviewers | Issues | Required | After | Gate owner | Created | Why |
+|---|---|---|---|---|---|---|---|---|
+| 1 | done | darkwing | #1503 | no | none | jason | 2026-09-13 | Done per lead decision 29. |
+| 2 | done | darkwing | #1504 | no | none | jason | 2026-09-13 | Brief: the plan page's Step 3. |
+| 3 | done | darkwing | #1505 | no | none | jason | 2026-09-13 | |
+| 4 | done | darkwing | #1506 | no | none | jason | 2026-09-13 | |
+| 5 | in-progress | dewey; filbert, rocko | #1507 | no | none | jason | 2026-09-13 | Rocko added as reviewer (adversarial lane). The note is shortened; the full State text is in `legacyView`. |
+| 6 | done | darkwing; dewey | #1511, #1512 | no | none | jason | 2026-09-13 | Done per lead decision 29. One brief per row, so the plan page's Piece 5; the other two briefs are named in the note. Dewey is the reviewer; Filbert authored the pilot. |
+| 7 | in-progress | sage | none | no | none | jason | 2026-09-13 | Kept as a live row, not retired (see question 1). Recurring, so `in-progress` with Sage's claim. |
+| 8 | parked | unassigned | none | no | none | jason | 2026-09-13 | Brief: Sage's stub (J9). |
+| 9 | in-progress | darkwing; filbert | #1508; closes none | yes, 2026-09-13 | none | jason | 2026-09-13 | No `after`: lead decision 29 took the wait on row 6 off rows 9 to 13. The note is written as of genesis. |
+| 10 | briefed | coordinator | #1508; closes none | yes, 2026-09-13 | none | jason | 2026-09-13 | Owner kept as `coordinator` (see question 2). No `after`: see question 3. |
+| 11 | waiting-on-jason | darkwing | #1508; closes none | yes, 2026-09-13 | none | jason | 2026-09-13 | Both parts are in A1 already, so only the gate is left (see question 4). |
+| 12 | briefed | darkwing | #1508; closes none | yes, 2026-09-13 | none | sage | 2026-09-13 | Gate owner `sage` (see question 5). |
+| 13 | briefed | darkwing; filbert | #1508 | yes, 2026-09-13 | none | sage | 2026-09-13 | Closes #1508; rows 9 to 12 close nothing. Gate owner `sage` (question 5). |
+| 14 | done | coordinator; filbert | #1509 | no | none | jason | 2026-09-13 | Reviewer lane named Filbert or orch-01; Filbert kept, orch-01 isn't a seat under `agents/`. |
+| 15 | done | coordinator | #1509 | no | none | jason | 2026-09-13 | |
+| 16 | waiting-on-jason | darkwing; filbert | #1510 | no | none | jason | 2026-09-13 | Kept open as `waiting-on-jason` (see question 6). |
+| 17 | done | coordinator | #1509 | no | none | jason | 2026-09-13 | |
+| 18 | done | darkwing | #1509 | no | none | jason | 2026-09-13 | The note keeps the coordinator's part. |
+| 19 | done | coordinator | #1509 | no | none | jason | 2026-09-13 | |
+| 20 | done | coordinator | #1509 | no | none | jason | 2026-09-13 | Done: lead decision 29 closed #1509 with rows 14 to 25 done. |
+| 21 | done | coordinator | #1509 | no | none | jason | 2026-09-14 | Done: lead decision 29. `rev-code-02` isn't a seat under `agents/`, so no reviewer. |
+| 22 | done | darkwing; filbert | #1503 | no | none | jason | 2026-09-14 | Done: #1503 closed (lead decision 29). |
+| 23 | done | coordinator | #1509 | no | none | jason | 2026-09-16 | Done per lead decision 29. |
+| 24 | done | coordinator | #1509 | no | none | jason | 2026-09-16 | Done per lead decision 29. |
+| 25 | done | coordinator | #1509 | no | none | jason | 2026-09-20 | Done per lead decision 29. |
+| 26 | parked | unassigned | none | no | none | jason | 2026-09-13 | Parked item 1. |
+| 27 | parked | unassigned | none | no | none | jason | 2026-09-13 | Parked item 2. |
+| 28 | parked | unassigned | none | no | none | jason | 2026-09-13 | Parked item 3. |
+| 29 | parked | unassigned | none | no | none | jason | unknown | Parked item 4. First committed 2026-09-26 (0f5b7cb9); no earlier evidence, so `unknown`. |
+| 30 | parked | unassigned | none | no | none | jason | 2026-09-13 | Parked item 5. |
+
+## Map
+
+```json queue-map
+{
+ "rows": [
+ {
+ "id": 1,
+ "piece": "Control board MVP (scanner + page)",
+ "owner": "darkwing",
+ "issues": [
+ 1503
+ ],
+ "closes": [
+ 1503
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "A passed 2026-09-12",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-12_control-board-mvp.md",
+ "anchor": "Control board MVP — plan"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done 2026-09-27 (Sage, lead decision 29): D-001 MVP delivered, Gate A passed 2026-09-12, attention status operator-accepted (row 22), relaunch activity, attribution and Host guard landed; #1503 closed; cross-harness board work belongs to Gate E (row 5)",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 2,
+ "piece": "Seat registration and `mosaic seat task`",
+ "owner": "darkwing",
+ "issues": [
+ 1504
+ ],
+ "closes": [
+ 1504
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "B passed 2026-09-12",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-12_control-board-mvp.md",
+ "anchor": "Step 3: daily use and fixes"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done 2026-09-13",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 3,
+ "piece": "Reply-from-board (piece 2 on the plan page)",
+ "owner": "darkwing",
+ "issues": [
+ 1505
+ ],
+ "closes": [
+ 1505
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "C passed 2026-09-12",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-12_control-board-mvp.md",
+ "anchor": "Piece 2: reply-from-board"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done 2026-09-12",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 4,
+ "piece": "Ledger (piece 3 on the plan page)",
+ "owner": "darkwing",
+ "issues": [
+ 1506
+ ],
+ "closes": [
+ 1506
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "D: 18.9 human messages per closed issue, week of 2026-09-06",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-12_control-board-mvp.md",
+ "anchor": "Piece 3: ledger (numbers for the rails)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done 2026-09-13",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 5,
+ "piece": "WebUI first screen on the Console design (piece 4 on the plan page)",
+ "owner": "dewey",
+ "issues": [
+ 1507
+ ],
+ "closes": [
+ 1507
+ ],
+ "state": "in-progress",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "E: all-seat interactive demonstration then Jason workday ruling; live cutover separately approved",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_webui-session-chat.md",
+ "anchor": "WebUI session chat: refined brief and delivery plan"
+ },
+ "after": [],
+ "reviewers": [
+ "filbert",
+ "rocko"
+ ],
+ "note": "CHAT-00/01/01C published (370823b3, 28d4e98a, b023841c); CHAT-02 done: backend a5beb6d9, Console c9e771cf, live check passed 2026-09-26; CHAT-03 brief approved and pinned 2026-09-27 (BRIEF.md 1ef15ac0, rescoped to Gate E, lead decisions 30 to 33; Filbert r3 approve, Rocko blocker closed, Filbert scope check pass); source author named by Sage after queue A2 lands; CHAT-04..08 not chartered; Gate E blocked",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 6,
+ "piece": "Darkwing on point: darkwing assigns and gates filbert's work (piece 5 on the plan page)",
+ "owner": "darkwing",
+ "issues": [
+ 1511,
+ 1512
+ ],
+ "closes": [
+ 1511,
+ 1512
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "F: filbert's item closes with zero human messages from Jason; code phase does not claim Gate F",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-12_control-board-mvp.md",
+ "anchor": "Piece 5: darkwing on point (orchestrator behaviour)"
+ },
+ "after": [],
+ "reviewers": [
+ "dewey"
+ ],
+ "note": "done 2026-09-27 (lead decision 29): #1511 and #1512 landed in af4203ca (pushed), closed; Gate F not passed (2 human messages in filbert's T3 thread; the first 11 days predate the T3 source); Gate G (row 9) carries the test. Filbert authored the pilot. Briefs also: 2026-09-15_relaunch-activity.md, 2026-09-14_task-attribution.md",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 7,
+ "piece": "Weekly ledger run and rails number",
+ "owner": "sage",
+ "issues": [],
+ "closes": [],
+ "state": "in-progress",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "under 10 human messages per closed issue for the week of 2026-09-13",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "packages/ledger/README.md",
+ "anchor": "Ledger"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "recurring, every Monday (lead decision 27); Jason reads; 09-13..19: 47.7, 09-20..26: 33.0 human messages per closed issue",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 8,
+ "piece": "Fleet seats (`~/.mosaic`) onto `mosaic launch`",
+ "owner": "unassigned",
+ "issues": [],
+ "closes": [],
+ "state": "parked",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Jason's call",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-26_fleet-seats-onto-mosaic-launch.md",
+ "anchor": "Row 8: fleet seats onto `mosaic launch` (stub brief)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "parked by current owner direction; no `~/.mosaic` changes during internal bootstrap",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 9,
+ "piece": "Queue as data: `docs/plans/queue.json`, `mosaic queue` is the only writer",
+ "owner": "darkwing",
+ "issues": [
+ 1508
+ ],
+ "closes": [],
+ "state": "in-progress",
+ "previousState": null,
+ "required": true,
+ "requiredSince": "2026-09-13",
+ "gate": "G: a fresh seat told only \"run `mosaic queue next` and do it\" starts its piece with zero human messages",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_queue-as-data.md",
+ "anchor": "Piece A: queue record and `mosaic queue` (QUEUE row 9)"
+ },
+ "after": [],
+ "reviewers": [
+ "filbert"
+ ],
+ "note": "A1 (journal, lock, CLI, verify) committed 34a72af9; A2 (migration, render, dispatch) committed with this map; Filbert approved both; genesis and hook install by Sage; Gate G also verifies row 10",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 10,
+ "piece": "Seats read the queue, not CURRENT.md (AGENTS.md cadence, seat context files)",
+ "owner": "coordinator",
+ "issues": [
+ 1508
+ ],
+ "closes": [],
+ "state": "briefed",
+ "previousState": null,
+ "required": true,
+ "requiredSince": "2026-09-13",
+ "gate": "verified by Gate G",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_queue-as-data.md",
+ "anchor": "Piece B: seats read the queue, not CURRENT.md (QUEUE row 10)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "AGENTS.md lines done 2026-09-13; the rest starts once genesis is committed",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 11,
+ "piece": "Brief template `docs/plans/BRIEF-TEMPLATE.md`; `queue add` refuses a missing brief",
+ "owner": "darkwing",
+ "issues": [
+ 1508
+ ],
+ "closes": [],
+ "state": "waiting-on-jason",
+ "previousState": null,
+ "required": true,
+ "requiredSince": "2026-09-13",
+ "gate": "two briefs accepted by Jason with no scope question",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_queue-as-data.md",
+ "anchor": "Piece C: brief template (QUEUE row 11)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "template and the `queue add` brief refusal both committed with A1 (34a72af9); the gate counts the first briefs added through the queue",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 12,
+ "piece": "Reviews as issue comments posted by `queue move ID in-review`, not files in `docs/plans/reviews/`",
+ "owner": "darkwing",
+ "issues": [
+ 1508
+ ],
+ "closes": [],
+ "state": "briefed",
+ "previousState": null,
+ "required": true,
+ "requiredSince": "2026-09-13",
+ "gate": "one review round with no new file under reviews/",
+ "gateOwner": "sage",
+ "brief": {
+ "path": "docs/plans/2026-09-13_queue-as-data.md",
+ "anchor": "Piece D: reviews through a channel, not files (QUEUE row 12)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "starts once genesis is committed; the live posting test needs the per-seat credential file (8.9)",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 13,
+ "piece": "Ledger \"queue\" section: issue/row/seat drift printed with the weekly number",
+ "owner": "darkwing",
+ "issues": [
+ 1508
+ ],
+ "closes": [
+ 1508
+ ],
+ "state": "briefed",
+ "previousState": null,
+ "required": true,
+ "requiredSince": "2026-09-13",
+ "gate": "first run Monday 2026-09-21, zero violations or every one moved same day",
+ "gateOwner": "sage",
+ "brief": {
+ "path": "docs/plans/2026-09-13_queue-as-data.md",
+ "anchor": "Piece E: ledger checks the queue (QUEUE row 13)"
+ },
+ "after": [],
+ "reviewers": [
+ "filbert"
+ ],
+ "note": "starts once genesis is committed; closes #1508 when done, so rows 9 to 12 close nothing; first run is the Monday after E is approved (Q7)",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 14,
+ "piece": "Discord connector pilot: Sage answers in Shared Signals (chat only, no tools, no repo writes)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "H: offline suite green, eight-step live pilot with private receipts, then Jason says the reply reads as Sage",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_discord-connector-pilot.md",
+ "anchor": "Discord connector pilot: Sage on Shared Signals"
+ },
+ "after": [],
+ "reviewers": [
+ "filbert"
+ ],
+ "note": "done: rev-code-02 APPROVE 26170 (round 9), committed 786e379c; pilot steps 1-8 done with private receipts, Gate H passed (Jason, 2026-09-13: replies read as Sage); connector left running for MVP iteration; reviewer lane was Filbert or orch-01",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 15,
+ "piece": "Discord connector: eyes reaction on every admitted message as a read receipt (MVP iteration 1)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Jason sees the reaction on a live message",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_discord-connector-pilot.md",
+ "anchor": "11. MVP iteration, after the pilot"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done: committed 93d6b624, live check passed 19:21 UTC (turn record receipt ok, Jason: test is successful), receipt `mvp1-read-receipt-20260913T192158Z.json` in the private evidence dir; reaction placed at admission before the engine runs, outcome in the turn record, none on drops or refusals; `scripts/test-discord.sh` 28/28 (90 node tests)",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 16,
+ "piece": "Repository-native development bootstrap: five internal agents, Darkwing coordination",
+ "owner": "darkwing",
+ "issues": [
+ 1510
+ ],
+ "closes": [
+ 1510
+ ],
+ "state": "waiting-on-jason",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "source approved; Researcher live response passed 26216; existing native Rocko lock preserved, no new Rocko model test; no MVP acceptance/publication",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_internal-development-bootstrap.md",
+ "anchor": "Repository-native development bootstrap"
+ },
+ "after": [],
+ "reviewers": [
+ "filbert"
+ ],
+ "note": "source approved by internal Filbert, exact R1 receipt 26204; six offline tests independently pass in both copies; 24 config tests and five no-effect checks are author evidence",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 17,
+ "piece": "Discord connector: systemd user service with a supervised pre-start (`recover`, exit 3 never retried) (MVP iteration 2)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "the Sage connector runs under `mosaic-discord@shared-signals`, survives a kill with a clean restart, and stays down behind `discord.sh stop`",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_discord-connector-pilot.md",
+ "anchor": "11. MVP iteration, after the pilot"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done, operator-verified by Jason 2026-09-13 (all four steps): suite 40/40 (95 node tests); Sage seat migrated 19:35 UTC, SIGKILL recovered in 16 s with the dead lock cleared, brake held (exit 3, no restart), released and READY; receipt `mvp2-service-unit-*.json` in the private evidence dir; the first cut (ExecStartPre) looped and was replaced by `run --supervised` before any traffic",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 18,
+ "piece": "Control board row for the Discord connector (MVP iteration 3): discovery from binding files, liveness from run.lock, reply refused",
+ "owner": "darkwing",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "a Sage (discord) row on the board shows live, offline and braked correctly, and reply from the board is refused",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-14_discord-board-row.md",
+ "anchor": "Discord connector board row"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done for bounded local delivery; Jason accepted visual test (parfait), R3 26257 reviewed, 322 tests and live 409 refusal verified; owner by Jason's ruling 2026-09-13, coordinator answered connector-side questions; also briefed in the pilot page section 11",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 19,
+ "piece": "Discord connector: binding reload without a restart (`reload` verb, SIGHUP, `systemctl --user reload`); channels, users, limits and guildName apply in place, identity, engine and context stay fixed, an invalid file is refused and the old binding kept (MVP iteration 4)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "edit the binding, run `scripts/discord.sh reload shared-signals`, the change applies with no restart, a broken edit is refused and journaled",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_discord-connector-pilot.md",
+ "anchor": "11. MVP iteration, after the pilot"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done: caaef941; live 00:03 UTC: reload applied Carmen's entry with no restart, unknown key refused by the CLI (exit 2), fixed key refused in the process with the binding kept, `systemctl --user reload` applied; suite 41/41 (101 node tests); receipt `mvp4-5-reload-carmen-*.json`",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 20,
+ "piece": "Discord connector: per-user channel allowlist in the binding and Carmen enrolled (all listed rooms except #sage-admin) (MVP iteration 5)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Carmen gets a reply in #general and silence in #sage-admin; Jason unchanged",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_discord-connector-pilot.md",
+ "anchor": "11. MVP iteration, after the pilot"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done: caaef941 (`users[].channels` allowlist, `channel-not-for-user` drop); Carmen enrolled live by reload 00:03 UTC; her first message was the remaining check; #1509 closed with rows 14 to 25 done (lead decision 29)",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 21,
+ "piece": "Discord connector: read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (MVP iteration 6)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Sage answers a question from a file under a declared root with the reads in the turn record; a read outside the roots is refused and recorded",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-14_discord-readonly-tools.md",
+ "anchor": "Discord Sage: read-only tools (iteration 6)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "approved: rev-code-02 round 2 verdict 26276 (tree 43f0329b), committed; Jason ruled R1–R7 2026-09-14 (roots docs/ and agents/sage/, Carmen included); done with rows 14 to 25 when #1509 closed (lead decision 29); reviewer per Q12",
+ "blockedReason": null,
+ "createdAt": "2026-09-14"
+ },
+ {
+ "id": 22,
+ "piece": "Board attention status: completed replies idle, explicit human input waiting",
+ "owner": "darkwing",
+ "issues": [
+ 1503
+ ],
+ "closes": [
+ 1503
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "operator accepted full targeted status sequence: explicit request waiting, Seen hides attention but preserves waiting, completion returns idle; broader MVP/cross-harness acceptance and publication separate",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_board-attention-status.md",
+ "anchor": "Board attention status correction"
+ },
+ "after": [],
+ "reviewers": [
+ "filbert"
+ ],
+ "note": "source approved, receipt 26248; reviewer 193 per copy, author union 213 pass; actual Researcher scan idle; operator accepted (row 1); #1503 closed (lead decision 29)",
+ "blockedReason": null,
+ "createdAt": "2026-09-14"
+ },
+ {
+ "id": 23,
+ "piece": "Discord connector: writes confined to the `shared-signals` root plus web fetch and search for the Discord Sage (MVP iteration 7)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Sage writes a naming shortlist into the repository from #ideas with the write and web calls in the turn record; a write outside the root is refused",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-16_discord-write-and-web-tools.md",
+ "anchor": "Discord Sage: writes into the strategy repository, and web research (#1509, QUEUE row 23)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done 2026-09-27 (Sage, lead decision 29): committed and pushed 1685deb4; live turns wrote `vault/Businesses/naming.md` with web_search and web_fetch in the turn record; the outside-root refusal rests on the offline suite, not a live turn; reviewer per Q12",
+ "blockedReason": null,
+ "createdAt": "2026-09-16"
+ },
+ {
+ "id": 24,
+ "piece": "Discord connector: git verbs (status, commit, pull ff-only, push) for the Discord Sage on the `shared-signals` root, seat identity through the existing credential helper (MVP iteration 8)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Sage commits and pushes a decision file from #ideas; GitHub shows Sage as author with a Requested-by trailer; no token in any record",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-16_discord-git-tools.md",
+ "anchor": "Discord Sage: git for the strategy repository (#1509, QUEUE row 24)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done 2026-09-27 (Sage, lead decision 29): committed and pushed 1949ed8d; live-configured 2026-09-18, never used from Discord; records now go through row 25 and SetSpark cutover freezes `vault/`, so first real use is the check and a failure opens a new issue; reviewer per Q12",
+ "blockedReason": null,
+ "createdAt": "2026-09-16"
+ },
+ {
+ "id": 25,
+ "piece": "Discord connector: SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (MVP iteration 9)",
+ "owner": "coordinator",
+ "issues": [
+ 1509
+ ],
+ "closes": [
+ 1509
+ ],
+ "state": "done",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Sage creates one work item from #sage-admin and the API shows it with revision 1; a proposal is approved by the Approve button and the audit row carries Sage's key id and Jason's Discord id separately; rev-code-02 approves on #1509",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-20_discord-setspark-client.md",
+ "anchor": "Discord Sage: SetSpark record client (#1509, QUEUE row 25)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "done 2026-09-26: committed and pushed 43d7574d, live check passed (DEC-010, request 2, approved by button at 21:38:58Z, lead decision 19); approver rule enforced by the service since shared-signals cc74d92 (lead decisions 24 and 28); reviewer per Q12",
+ "blockedReason": null,
+ "createdAt": "2026-09-20"
+ },
+ {
+ "id": 26,
+ "piece": "Registry increment 3 (headless identity-env leak)",
+ "owner": "unassigned",
+ "issues": [],
+ "closes": [],
+ "state": "parked",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Jason reopens",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/CURRENT.md",
+ "anchor": "Completed checkpoint: #1500 increment 2 (historical)"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "#1500 closed on increment 2; Jason has not asked for 3",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 27,
+ "piece": "Auth/provider/harness registry review",
+ "owner": "unassigned",
+ "issues": [],
+ "closes": [],
+ "state": "parked",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Jason reopens",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-03_auth-provider-harness-registry.md",
+ "anchor": "Harness declaration + centralized auth/provider registry"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "Paused for owner alignment",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 28,
+ "piece": "CI runners, second real adapter, push automation",
+ "owner": "unassigned",
+ "issues": [],
+ "closes": [],
+ "state": "parked",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Jason reopens",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/ROADMAP.md",
+ "anchor": "Explicitly deferred"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "Deferred by owner 2026-09-03",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ },
+ {
+ "id": 29,
+ "piece": "Console features outside the refined session-chat brief, including Fresh creation and model switching",
+ "owner": "unassigned",
+ "issues": [],
+ "closes": [],
+ "state": "parked",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Jason reopens",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/2026-09-13_webui-session-chat.md",
+ "anchor": "WebUI session chat: refined brief and delivery plan"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "Deferred by WEBUI Q1; required history/control/stop now belong to row 5, not this parked item",
+ "blockedReason": null,
+ "createdAt": "unknown"
+ },
+ {
+ "id": 30,
+ "piece": "Open gaps from the MVP work",
+ "owner": "unassigned",
+ "issues": [],
+ "closes": [],
+ "state": "parked",
+ "previousState": null,
+ "required": false,
+ "requiredSince": null,
+ "gate": "Jason reopens",
+ "gateOwner": "jason",
+ "brief": {
+ "path": "docs/plans/DEFERRED.md",
+ "anchor": "Open"
+ },
+ "after": [],
+ "reviewers": [],
+ "note": "Fixed only when a gate needs them",
+ "blockedReason": null,
+ "createdAt": "2026-09-13"
+ }
+ ],
+ "retired": [],
+ "highWater": 30
+}
+```
diff --git a/packages/queue/README.md b/packages/queue/README.md
index 1c8774fa4113aac80e6f1fb42cc4dddfb187bb5f..d7323e03b15c2f53f272600d8dcc3012bb86f6da 100644
--- a/packages/queue/README.md
+++ b/packages/queue/README.md
@@ -7,14 +7,15 @@ specification is section 8 of
`agents/filbert/work/queue-as-data-plan-2026-09-26.md`. This README states
what the code does and the limits it accepts.
-Until A2 adds `scripts/mosaic queue`, run the CLI directly from the canonical
-checkout. Messages already name `scripts/mosaic queue`.
+Run it from the canonical checkout as `scripts/mosaic queue <verb>`, which
+execs `node packages/queue/src/cli.mjs` with the same arguments and exit
+code.
```sh
-node packages/queue/src/cli.mjs list
-node packages/queue/src/cli.mjs next darkwing
-node packages/queue/src/cli.mjs move 9 in-progress --op darkwing-9-start-1 --by darkwing
-node packages/queue/src/cli.mjs verify --current
+scripts/mosaic queue list
+scripts/mosaic queue next darkwing
+scripts/mosaic queue move 9 in-progress --op darkwing-9-start-1 --by darkwing
+scripts/mosaic queue verify --current
node --test packages/queue/tests/
scripts/test-queue.sh
```
@@ -35,8 +36,20 @@ scripts/test-queue.sh
Every change takes `--op ID` (8 to 72 characters of `[a-z0-9._-]`,
starting with a letter or digit, not ending in `.outcome`) and an actor from `--by NAME` or
-`$MOSAIC_AGENT_NAME`. Reads need no actor. `next` with neither a SEAT nor
-`$MOSAIC_AGENT_NAME` refuses. The privileged actors are `jason` and `sage`.
+`$MOSAIC_AGENT_NAME`. `--by` wins when both are set; if they differ, the
+command prints a warning on stderr and goes on. Reads need no actor. `next`
+with neither a SEAT nor `$MOSAIC_AGENT_NAME` refuses. The privileged actors
+are `jason` and `sage`.
+
+Text the table shows (piece, gate, note, the move reason, the brief anchor,
+a blocked reason) refuses `\` and `<`. The table escapes `|` itself; a
+backslash could undo that escape and `<` starts raw HTML, so the CLI refuses
+both rather than escape them.
+
+`set ID issues` also moves `closes`. If `closes` equals the old issues, it
+becomes the new list. If it was narrowed (`set ID closes` with `--reason`,
+or genesis from the map), it keeps the part still among the new issues, and
+the receipt ends in `(kept narrowed)`.
Exit codes: 0 ok; 1 the operation failed; 2 invalid data or refused;
3 uncertain (visible or durable, not acknowledged); 4 usage.
@@ -50,7 +63,8 @@ The review's issue follows lead decision 23. A row with no issues can't
request review. A row with one issue uses it. A row with several needs
`--issue N`, one of its issues. Later rounds keep the previous round's issue
unless `--issue` names another; if the row no longer lists the kept issue,
-the request refuses until `--issue` names one.
+the request refuses until `--issue` names one. Each round records the issue
+it used (`review.rounds[].issue`).
`move ID done` from in-review needs `--evidence
comment=<id>,round=<n>,candidate=<digest>`. The round must be the current
@@ -146,12 +160,23 @@ the guard, runs `queue genesis` from the reviewed migration map, then
`genesis not committed` until that commit exists. After that,
`scripts/test-queue.sh` also runs `verify` on the live queue.
+The map is `agents/darkwing/work/queue-migration-map.md`. It names the
+QUEUE.md blob it was built from. Before genesis, `node
+agents/darkwing/work/queue-a2/map-check.mjs` lists every table line changed
+since that blob and every row whose piece, owner or issues no longer match
+the map; exit 0 means no drift. QUEUE.md needs its two markers around the
+table and the parked list before genesis, since genesis keeps the text
+between them as `legacyView`.
+
Committing still needs its own authorization. The script never pushes.
## Durability
-Supported: Linux with `docs/plans/` and `.git/` on local ext4, xfs or btrfs
-(tmpfs for tests). The CLI checks the filesystem type and refuses others.
+Supported: Linux with `docs/plans/` and `.git/` on local ext4, xfs or btrfs.
+The CLI checks the filesystem type and refuses others. ext2 and ext3 report
+ext4's magic number, so the check can't tell them apart. tmpfs passes only
+for a test that hands in an io layer with `allowTmpfs: true`; the CLI's own
+layer never has it.
The host-crash claims assume that rename replaces its target atomically and
that fsync of a directory persists the renamed entry. The tests kill
processes with SIGKILL at each step. **Power loss is not tested.**
@@ -198,6 +223,9 @@ reporting, so a write in progress is never reported as lost history.
- `queue unlock --check-gate` classifies a stale unlock gate. Remove the
gate by hand only after it says `dead` or `mismatch` and no queue command
is running.
+- A process killed while taking the lock can leave
+ `.git/mosaic-queue.lock.<pid>.<hex>.tmp`. Nothing reads it or removes it.
+ Delete it by hand once that pid is gone.
## Manual recovery
@@ -240,5 +268,10 @@ Faults reach the code only through options the tests pass in (`io`, `proc`,
views, snapshot.
- `write.test.mjs`: every write fault, SIGKILL at each step, git
interference, unlocked-read races.
+- `dispatch.test.mjs`: `scripts/mosaic queue` passes arguments and exit
+ codes through, and the seat commands still reach the seat CLI.
+- `migration.test.mjs`: the real map renders the golden genesis table, the
+ marked QUEUE.md fixture holds every row between its markers, and
+ `map-check.mjs` reports each kind of drift.
- `commit.test.mjs`: `queue-commit.sh` and the guard, through PATH shims
that run an action at an exact point in the procedure.
diff --git a/packages/queue/src/cli.mjs b/packages/queue/src/cli.mjs
index e8df80f73dfd977952a27ebd46725694ce7771cf..fb7cff61efecad5936a3ea2cf6b2965458fd29bd 100644
--- a/packages/queue/src/cli.mjs
+++ b/packages/queue/src/cli.mjs
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// Usage: queue <verb> [args] (A2 adds `scripts/mosaic queue` in front).
+// Usage: scripts/mosaic queue <verb> [args], or node packages/queue/src/cli.mjs.
//
// Reads: list | show ID | next [SEAT]
// Changes: add --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT]
diff --git a/packages/queue/src/io.mjs b/packages/queue/src/io.mjs
index 6ebb63cbc4fa938e9ffd756a53ce1181b576e185..802ebefdc18a14a93afaa270d08acdba68a9c69f 100644
--- a/packages/queue/src/io.mjs
+++ b/packages/queue/src/io.mjs
@@ -26,14 +26,17 @@ export const realIo = Object.freeze({
statfsType: (path) => statfsSync(path).type,
});
-// ext4 (ext2/ext3 share the magic), xfs, btrfs; tmpfs for tests.
-const FS_TYPES = new Map([[0xef53, "ext4"], [0x58465342, "xfs"], [0x9123683e, "btrfs"], [0x01021994, "tmpfs"]]);
+// ext4 (ext2/ext3 share the magic), xfs, btrfs. tmpfs only for a test that
+// passes a layer with `allowTmpfs: true`; realIo never has it (N5).
+const FS_TYPES = new Map([[0xef53, "ext4"], [0x58465342, "xfs"], [0x9123683e, "btrfs"]]);
+export const TMPFS = 0x01021994;
export function checkPlatform(io, dirs) {
if (process.platform !== "linux") throw new QueueError(`unsupported platform ${process.platform}: the queue runs on Linux only`, 2);
for (const dir of dirs) {
const type = io.statfsType(dir);
- if (!FS_TYPES.has(type)) throw new QueueError(`unsupported filesystem under ${dir} (type 0x${type.toString(16)}); ext4, xfs, btrfs or tmpfs only`, 2);
+ if (type === TMPFS && io.allowTmpfs === true) continue;
+ if (!FS_TYPES.has(type)) throw new QueueError(`unsupported filesystem under ${dir} (type 0x${type.toString(16)}); ext4, xfs or btrfs only`, 2);
}
}
diff --git a/packages/queue/src/lock.mjs b/packages/queue/src/lock.mjs
index 34e88e3b2a3b5914e53ea00dbab270d68f01d876..2fc532bc7ef0eb25cceca992aaec2804b537329b 100644
--- a/packages/queue/src/lock.mjs
+++ b/packages/queue/src/lock.mjs
@@ -129,16 +129,26 @@ export function acquire({ gitDir, io, proc = realProc, op = null, verb, waitMs =
try {
if (lstatOrNull(io, gate) !== null) c = classify(readOrNull(io, gate), proc);
} catch (err) {
- const left = release(handle, io);
+ const left = releaseOrWarn(handle, io);
throw new QueueError(`cannot check the unlock gate ${gate}: ${errno(err)}; ${left ?? "lock released"}`, 1);
}
if (c !== null) {
- release(handle, io);
- throw new QueueError(`unlock gate ${gate} is present (${describe(c)}); check it with \`scripts/mosaic queue unlock --check-gate\``, 2);
+ const left = releaseOrWarn(handle, io);
+ throw new QueueError(`unlock gate ${gate} is present (${describe(c)}); check it with \`scripts/mosaic queue unlock --check-gate\`${left ? `\nwarning: ${left}` : ""}`, 2);
}
return handle;
}
+// release() on a path that is already reporting something: a throw becomes
+// a message naming the lock that may be left behind (P1).
+export function releaseOrWarn(handle, io) {
+ try {
+ return release(handle, io);
+ } catch (err) {
+ return `cannot release the queue lock (${errno(err)}); ${handle.path} may be left in place`;
+ }
+}
+
// Unlinks only the lock this process published: same inode, same record.
export function release(handle, io) {
const st = lstatOrNull(io, handle.path);
@@ -185,7 +195,8 @@ export function unlock({ gitDir, io, proc = realProc, hook = () => {} }) {
// Like the lock, a swapped gate is reported on success and on refusal (8.4).
if (msg && failure instanceof Error) failure.message += `\nwarning: ${msg}`;
if (failure) throw failure;
- return msg ? `${result}\nwarning: ${msg}` : result;
+ // Apart, so the caller never splits a lock record's text to find it (P3).
+ return { result, warning: msg };
}
export function checkGate({ gitDir, io, proc = realProc }) {
diff --git a/packages/queue/src/queue.mjs b/packages/queue/src/queue.mjs
index 06e77c17fa6af91b3384ef66c6228edaf4687249..d0a6cf9a471dc2af717cfe6b8b1a342b70957602 100644
--- a/packages/queue/src/queue.mjs
+++ b/packages/queue/src/queue.mjs
@@ -16,6 +16,9 @@ export const SET_FIELDS = ["piece", "gate", "gate-owner", "after", "reviewers",
const NAME_RE = /^[a-z][a-z0-9-]{0,31}$/;
export const CALLER_OP_RE = /^[a-z0-9][a-z0-9._-]{7,71}$/;
+// Room for `<op>.outcome`, which only an op id the CLI derives may use. No
+// verb derives one before Piece D, so replay applies CALLER_OP_RE to every
+// entry, genesis included.
export const LOG_OP_RE = /^[a-z0-9][a-z0-9._-]{7,79}$/;
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -25,6 +28,9 @@ const PATH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
const BRANCH_RE = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
// C0, DEL, C1, U+2028 and U+2029: none belongs in a one-line field.
const BAD_TEXT_RE = new RegExp(`[${String.fromCharCode(0)}-${String.fromCharCode(0x1f)}${String.fromCharCode(0x7f)}-${String.fromCharCode(0x9f)}${String.fromCharCode(0x2028, 0x2029)}]`);
+// A backslash can escape the pipe `cell()` escapes, and `<` starts raw HTML.
+// Text the table shows refuses both rather than escaping them (N8).
+const BAD_CELL_RE = /[\\<]/;
export const DOC_KEYS = ["version", "canonicalRoot", "revision", "rows", "log"];
export const ROW_KEYS = [
@@ -74,6 +80,20 @@ export function checkText(v, what, { max = 500, empty = false } = {}) {
return v;
}
+// Text that lands in a table cell.
+export function checkCellText(v, what, opts) {
+ checkText(v, what, opts);
+ if (BAD_CELL_RE.test(v)) throw refuse(`${what} must not contain \\ or <, which the rendered table cannot show safely`);
+ return v;
+}
+
+// An op id a caller chose: 8 to 72 characters, not ending in `.outcome`.
+export function checkCallerOpId(v, what = "op id") {
+ if (typeof v !== "string" || !CALLER_OP_RE.test(v)) throw refuse(`${what} ${JSON.stringify(v)} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
+ if (v.endsWith(".outcome")) throw refuse(`${what} ${JSON.stringify(v)} ends in .outcome, which is reserved`);
+ return v;
+}
+
export function checkName(v, what) {
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens): ${JSON.stringify(v)}`);
return v;
@@ -127,14 +147,14 @@ export function parseBriefSpec(spec) {
const i = spec.indexOf("#");
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading: ${JSON.stringify(spec)}`);
const path = checkRepoPath(spec.slice(0, i), "brief path");
- const anchor = checkText(spec.slice(i + 1), "brief anchor", { max: 200 });
+ const anchor = checkCellText(spec.slice(i + 1), "brief anchor", { max: 200 });
return { path, anchor };
}
function checkBrief(v, what = "brief") {
keysExactly(v, ["path", "anchor", "blob"], what);
checkRepoPath(v.path, `${what} path`);
- checkText(v.anchor, `${what} anchor`, { max: 200 });
+ checkCellText(v.anchor, `${what} anchor`, { max: 200 });
if (!BLOB_RE.test(v.blob)) throw refuse(`${what} blob must be a 40-hex git blob id`);
return v;
}
@@ -168,11 +188,12 @@ export function parseManifest(text) {
}
function checkRound(v, n) {
- keysExactly(v, ["n", "op", "by", "at", "candidate", "request"], "review round");
+ keysExactly(v, ["n", "op", "by", "at", "issue", "candidate", "request"], "review round");
if (v.n !== n) throw refuse(`review rounds must be numbered from 1; expected ${n}`);
- if (!LOG_OP_RE.test(v.op)) throw refuse("review round op is not an op id");
+ checkCallerOpId(v.op, "review round op");
checkName(v.by, "review round by");
checkTime(v.at, "review round at");
+ checkId(v.issue, "review round issue");
checkCandidate(v.candidate);
if (v.request !== "none") throw refuse("review round request must be none before Piece D");
}
@@ -181,7 +202,7 @@ export function validateRow(row) {
keysExactly(row, ROW_KEYS, `row ${row?.id}`);
const w = `row ${row.id}`;
checkId(row.id);
- checkText(row.piece, `${w} piece`, { max: 300 });
+ checkCellText(row.piece, `${w} piece`, { max: 300 });
checkName(row.owner, `${w} owner`);
checkIssues(row.issues, `${w} issues`);
checkIssues(row.closes, `${w} closes`);
@@ -189,7 +210,7 @@ export function validateRow(row) {
if (!STATES.includes(row.state)) throw refuse(`${w} state ${JSON.stringify(row.state)} is not a state`);
if (row.state === "blocked") {
if (!NON_TERMINAL.has(row.previousState)) throw refuse(`${w} is blocked and needs the non-terminal state it returns to`);
- checkText(row.blockedReason, `${w} blockedReason`);
+ checkCellText(row.blockedReason, `${w} blockedReason`);
} else if (row.previousState !== null || row.blockedReason !== null) {
throw refuse(`${w} carries previousState or blockedReason but is not blocked`);
}
@@ -197,7 +218,7 @@ export function validateRow(row) {
if (row.required) checkTime(row.requiredSince, `${w} requiredSince`, { unknown: true, date: true });
else if (row.requiredSince !== null) throw refuse(`${w} has requiredSince but is not required`);
if (row.required && row.state === "parked") throw refuse(`${w} is parked and required`);
- checkText(row.gate, `${w} gate`, { max: 300 });
+ checkCellText(row.gate, `${w} gate`, { max: 300 });
checkName(row.gateOwner, `${w} gateOwner`);
if (row.brief === null) {
if (row.state !== "done") throw refuse(`${w} has no brief; only a done row may lack one`);
@@ -207,19 +228,18 @@ export function validateRow(row) {
checkAfter(row.after, `${w} after`);
checkNames(row.reviewers, `${w} reviewers`);
if (row.review !== null) {
- keysExactly(row.review, ["issue", "rounds"], `${w} review`);
- checkId(row.review.issue, `${w} review issue`);
+ keysExactly(row.review, ["rounds"], `${w} review`);
if (!Array.isArray(row.review.rounds) || row.review.rounds.length === 0) throw refuse(`${w} review needs at least one round`);
row.review.rounds.forEach((r, i) => checkRound(r, i + 1));
}
if (row.claim !== null) {
keysExactly(row.claim, ["seat", "op"], `${w} claim`);
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat ${row.claim.seat} is not the owner ${row.owner}`);
- if (!LOG_OP_RE.test(row.claim.op)) throw refuse(`${w} claim op is not an op id`);
+ checkCallerOpId(row.claim.op, `${w} claim op`);
const s = row.state === "blocked" ? row.previousState : row.state;
if (!CLAIM_KEPT.has(s)) throw refuse(`${w} is claimed but ${row.state}`);
}
- if (row.note !== null) checkText(row.note, `${w} note`);
+ if (row.note !== null) checkCellText(row.note, `${w} note`);
checkTime(row.createdAt, `${w} createdAt`, { unknown: true, date: true });
checkTime(row.updatedAt, `${w} updatedAt`);
checkName(row.updatedBy, `${w} updatedBy`);
@@ -291,8 +311,8 @@ export function canonAfter(v) {
function canonSetValue(field, value) {
switch (field) {
- case "piece": return checkText(value, "piece", { max: 300 });
- case "gate": return checkText(value, "gate", { max: 300 });
+ case "piece": return checkCellText(value, "piece", { max: 300 });
+ case "gate": return checkCellText(value, "gate", { max: 300 });
case "gate-owner": return checkName(value, "gate owner");
case "after": return canonAfter(value);
case "reviewers": return canonNames(value, "reviewers");
@@ -318,11 +338,11 @@ export function canonArgs(verb, a) {
const brief = a.brief;
parseBriefSpec(brief);
return {
- piece: checkText(a.piece, "piece", { max: 300 }),
- gate: checkText(a.gate, "gate", { max: 300 }),
+ piece: checkCellText(a.piece, "piece", { max: 300 }),
+ gate: checkCellText(a.gate, "gate", { max: 300 }),
brief,
issues: canonIssues(a.issues ?? []),
- note: nullable(a.note, (v) => checkText(v, "note")),
+ note: nullable(a.note, (v) => checkCellText(v, "note")),
owner: nullable(a.owner, (v) => checkName(v, "owner")),
gateOwner: nullable(a.gateOwner, (v) => checkName(v, "gate owner")),
after: nullable(a.after, canonAfter),
@@ -335,7 +355,7 @@ export function canonArgs(verb, a) {
return {
id: checkId(a.id),
to: a.to,
- reason: nullable(a.reason, (v) => checkText(v, "reason")),
+ reason: nullable(a.reason, (v) => checkCellText(v, "reason")),
candidate: nullable(a.candidate, (v) => checkText(v, "candidate", { max: 300 })),
evidence: nullable(a.evidence, (v) => checkText(v, "evidence")),
issue: nullable(a.issue, (v) => checkId(v, "issue")),
@@ -345,7 +365,7 @@ export function canonArgs(verb, a) {
case "assign":
return { id: checkId(a.id), seat: checkName(a.seat, "seat") };
case "note":
- return { id: checkId(a.id), text: checkText(a.text, "note", { empty: true }) };
+ return { id: checkId(a.id), text: checkCellText(a.text, "note", { empty: true }) };
case "set": {
if (!SET_FIELDS.includes(a.field)) throw refuse(`set cannot change ${JSON.stringify(a.field)}; fields: ${SET_FIELDS.join(", ")}`);
return {
@@ -404,7 +424,8 @@ export function parseReviewEvidence(text) {
// The issue a review round posts to (lead decision 23). The row must list
// one; with several, --issue names it. A later round keeps the previous
// round's issue unless --issue names another, and the kept issue must still
-// be one of the row's.
+// be one of the row's. Each round records its own issue (P2), so the kept
+// one is the last round's.
function reviewIssue(row, issue) {
const list = row.issues.map((n) => `#${n}`).join(", ");
if (row.issues.length === 0) throw refuse(`row ${row.id} lists no issues; a privileged actor sets one before review`);
@@ -413,8 +434,9 @@ function reviewIssue(row, issue) {
return issue;
}
if (row.review) {
- if (!row.issues.includes(row.review.issue)) throw refuse(`row ${row.id}'s review issue #${row.review.issue} is no longer one of its issues (${list}); name one with --issue`);
- return row.review.issue;
+ const kept = row.review.rounds.at(-1).issue;
+ if (!row.issues.includes(kept)) throw refuse(`row ${row.id}'s review issue #${kept} is no longer one of its issues (${list}); name one with --issue`);
+ return kept;
}
if (row.issues.length > 1) throw refuse(`row ${row.id} lists several issues (${list}); name the review's issue with --issue`);
return row.issues[0];
@@ -484,8 +506,7 @@ function applyMove(rows, row, entry, resolved) {
const rounds = row.review ? row.review.rounds : [];
round = rounds.length + 1;
next.review = {
- issue: revIssue,
- rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, candidate: cand, request: "none" }],
+ rounds: [...rounds, { n: round, op: entry.op, by, at: entry.at, issue: revIssue, candidate: cand, request: "none" }],
};
} else if (from === "in-review" && (to === "in-progress" || to === "waiting-on-jason")) {
ownerOrPriv(row, by, `move to ${to}`);
@@ -529,7 +550,9 @@ function applySet(rows, row, entry, resolved) {
case "piece": case "gate": case "reviewers": case "issues":
requirePriv(by, `change ${field}`);
next[field] = value;
- if (field === "issues") next.closes = value;
+ // N10: closes follows the issues only if nobody narrowed it. A logged
+ // narrowing keeps its intersection with the new issues.
+ if (field === "issues") next.closes = sameJson(row.closes, row.issues) ? value : row.closes.filter((n) => value.includes(n));
break;
case "gate-owner":
requirePriv(by, "change the gate owner");
@@ -570,7 +593,9 @@ function applySet(rows, row, entry, resolved) {
if (sameJson(next[key], row[key]) && (field !== "issues" || sameJson(next.closes, row.closes))) {
throw refuse(`row ${row.id} ${key} is already ${fmt(row[key])}`);
}
- return { row: touch(next, entry), result: { row: row.id, field: key, from: row[key], to: next[key] } };
+ const result = { row: row.id, field: key, from: row[key], to: next[key] };
+ if (field === "issues") result.closes = { from: row.closes, to: next.closes, narrowed: !sameJson(row.closes, row.issues) };
+ return { row: touch(next, entry), result };
}
// One log entry against the state before it. `resolved` carries what the
@@ -652,7 +677,8 @@ export function applyEntry(state, entry, resolved) {
const out = applySet(rows, row, entry, resolved);
rows.set(row.id, out.row);
const r = out.result;
- result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.field}: ${fmt(r.from)}→${fmt(r.to)}`) };
+ const closes = r.closes ? `; closes: ${fmt(r.closes.from)}→${fmt(r.closes.to)}${r.closes.narrowed ? " (kept narrowed)" : ""}` : "";
+ result = { ...r, receipt: receipt(entry, rev, `row ${row.id} ${r.field}: ${fmt(r.from)}→${fmt(r.to)}${closes}`) };
break;
}
case "accept-history": {
@@ -769,7 +795,7 @@ export function rowsArray(state) {
function checkEntryShape(e, i) {
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
- if (typeof e.op !== "string" || !LOG_OP_RE.test(e.op)) throw refuse(`log entry ${i} op ${JSON.stringify(e.op)} is not an op id`);
+ checkCallerOpId(e.op, `log entry ${i} op`);
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`);
checkName(e.by, `log entry ${i} by`);
checkTime(e.at, `log entry ${i} at`);
@@ -815,7 +841,6 @@ export function replay(log) {
const e = log[i];
if (e.verb === "genesis") throw refuse(`log entry ${i} is a second genesis`);
if (!sameJson(canonArgs(e.verb, e.args), e.args)) throw refuse(`log entry ${i} args are not canonical`);
- if (e.verb !== "accept-history" && e.op.endsWith(".outcome")) throw refuse(`log entry ${i} uses the reserved .outcome suffix`);
let out;
try {
out = applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result));
diff --git a/packages/queue/src/store.mjs b/packages/queue/src/store.mjs
index c3a66b8c98e57e657aac1879842cad5724596b76..2af77d8c58d54ae9cfd5e432bd4dfdc752e4efbe 100644
--- a/packages/queue/src/store.mjs
+++ b/packages/queue/src/store.mjs
@@ -9,9 +9,9 @@ import { isAbsolute, join, resolve as resolvePath } from "node:path";
import { fileURLToPath } from "node:url";
import { QueueError } from "./errors.mjs";
import { checkPlatform, errno, fsyncFile, lstatOrNull, readOrNull, realIo, unlinkQuiet, writeTemp } from "./io.mjs";
-import { acquire, checkGate, realProc, release, unlock as unlockLock } from "./lock.mjs";
+import { acquire, checkGate, realProc, releaseOrWarn, unlock as unlockLock } from "./lock.mjs";
import {
- CALLER_OP_RE, PRIVILEGED, SEMANTICS, VERSION, applyEntry, buildDoc, canonArgs, checkName, classifyView, countHeading,
+ PRIVILEGED, SEMANTICS, VERSION, applyEntry, buildDoc, canonArgs, checkCallerOpId, checkName, classifyView, countHeading,
describeUnshown, genesisReceipt, genesisRows, gitBlobId, loadDoc, logDigest, nextFor, parseBriefSpec, parseManifest,
parseMigrationMap, render, rowsArray, sameJson, serialize, sha256, splitView,
} from "./queue.mjs";
@@ -95,6 +95,14 @@ function actorOf(ctx, by) {
return checkName(name, "actor");
}
+// N12: --by wins over MOSAIC_AGENT_NAME, and a difference is worth a line on
+// stderr. Both are self-asserted (J2), so nothing is logged.
+function actorMismatch(ctx, by) {
+ const env = ctx.env.MOSAIC_AGENT_NAME;
+ if (by === null || by === undefined || env === undefined || env === "" || by === env) return null;
+ return `warning: --by ${JSON.stringify(by)} differs from MOSAIC_AGENT_NAME=${JSON.stringify(env)}`;
+}
+
function isSeatDir(top, name) {
try {
return lstatSync(join(top, "agents", name)).isDirectory();
@@ -417,8 +425,7 @@ function withLock(ctx, loc, { op = null, verb }, fn) {
} catch (err) {
failure = err;
}
- let msg;
- try { msg = release(handle, ctx.io); } catch (err) { msg = `cannot release the queue lock (${errno(err)})`; }
+ const msg = releaseOrWarn(handle, ctx.io);
// A refusal still reports what release found (8.4).
if (msg && failure instanceof Error) failure.message += `\nwarning: ${msg}`;
else if (msg) res.err.push(`warning: ${msg}`);
@@ -428,8 +435,7 @@ function withLock(ctx, loc, { op = null, verb }, fn) {
function checkCallerOp(op) {
if (typeof op !== "string" || op === "") throw new QueueError("--op ID is required; choose it before the first attempt and reuse it on every retry", 4);
- if (!CALLER_OP_RE.test(op)) throw refuse(`op id ${JSON.stringify(op)} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
- if (op.endsWith(".outcome")) throw refuse(`op ids ending in .outcome are reserved`);
+ checkCallerOpId(op);
}
// --- mutations ---
@@ -456,6 +462,18 @@ function resolveFor(ctx, loc, cur, verb, args, cmp) {
// it is not part of the op's identity.
export function mutate(opts, { verb, op, args, by, yes = false }) {
const ctx = makeCtx(opts);
+ const mismatch = actorMismatch(ctx, by);
+ try {
+ const res = mutateAs(ctx, { verb, op, args, by, yes });
+ if (mismatch) res.err.unshift(mismatch);
+ return res;
+ } catch (err) {
+ if (mismatch && err instanceof Error) err.message += `\n${mismatch}`;
+ throw err;
+ }
+}
+
+function mutateAs(ctx, { verb, op, args, by, yes }) {
checkCallerOp(op);
const actor = actorOf(ctx, by);
const cargs = canonArgs(verb, args);
@@ -776,6 +794,6 @@ export function unlock(opts, { checkGateOnly = false } = {}) {
const ctx = makeCtx(opts);
const loc = unlockLoc(ctx);
if (checkGateOnly) return { out: [checkGate({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc }).line], err: [], code: 0 };
- const [line, ...warnings] = unlockLock({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, hook: ctx.hook }).split("\n");
- return { out: [line], err: warnings, code: 0 };
+ const { result, warning } = unlockLock({ gitDir: loc.gitDir, io: ctx.io, proc: ctx.proc, hook: ctx.hook });
+ return { out: [result], err: warning ? [`warning: ${warning}`] : [], code: 0 };
}
diff --git a/packages/queue/tests/data.test.mjs b/packages/queue/tests/data.test.mjs
index 99080d9571b38540cebbe92086bfd6e568ffcf0f..85cb3d7eb8cca966ef6aeb9219c9055e5501434d 100644
--- a/packages/queue/tests/data.test.mjs
+++ b/packages/queue/tests/data.test.mjs
@@ -20,16 +20,16 @@ function brief(path = "docs/plans/brief-b.md", anchor = "Queue", blob = BLOB) {
}
// A genesis document built the way store.mjs builds one.
-function genesisDoc(rows = MAP_ROWS) {
+function genesisDoc(rows = MAP_ROWS, op = "genesis-op-0001") {
const map = parseMigrationMap(mapText(rows));
const blobs = new Map(map.rows.filter((r) => r.brief).map((r) => [r.id, BLOB]));
const t = at();
- const grows = genesisRows(map, blobs, "genesis-op-0001", t, "sage");
+ const grows = genesisRows(map, blobs, op, t, "sage");
const body = render(grows, 0);
const entry = {
- rev: 0, op: "genesis-op-0001", verb: "genesis", args: { root: ROOT, branch: "refactor", map: "agents/sage/work/queue-migration-map.md" },
+ rev: 0, op, verb: "genesis", args: { root: ROOT, branch: "refactor", map: "agents/sage/work/queue-migration-map.md" },
by: "sage", at: t, semantics: 1,
- result: { mapBlob: BLOB2, highWater: map.highWater, retired: map.retired, rows: grows, legacyView: "legacy\n", receipt: genesisReceipt("genesis-op-0001", grows.length) },
+ result: { mapBlob: BLOB2, highWater: map.highWater, retired: map.retired, rows: grows, legacyView: "legacy\n", receipt: genesisReceipt(op, grows.length) },
viewSha: sha256(body),
};
return { version: 1, canonicalRoot: ROOT, revision: 0, rows: grows, log: [entry] };
@@ -154,8 +154,8 @@ test("matrix: release, review round, changes requested and waiting-on-jason", ()
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x.sha256" }), "dewey", { candidate: CAND }), /only the claimant/);
d = step(d, "move", mv(9, "in-review", { candidate: "x.sha256" }), "darkwing", { candidate: CAND });
const rv = row(d, 9).review;
- assert.equal(rv.issue, 1508);
- assert.deepEqual(rv.rounds.map((r) => [r.n, r.request, r.candidate.digest]), [[1, "none", CAND.digest]]);
+ assert.deepEqual(Object.keys(rv), ["rounds"]);
+ assert.deepEqual(rv.rounds.map((r) => [r.n, r.issue, r.request, r.candidate.digest]), [[1, 1508, "none", CAND.digest]]);
assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1 on #1508$/);
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed by darkwing/);
d = step(d, "move", mv(9, "in-progress"), "darkwing");
@@ -209,13 +209,15 @@ function row12Started(issues, { gateOwner = "filbert" } = {}) {
const review = (d, extra = {}) => step(d, "move", mv(12, "in-review", { candidate: "x", ...extra }), "darkwing", { candidate: CAND });
const again = (d) => step(d, "move", mv(12, "in-progress"), "darkwing");
+// The issue the next round keeps: the last round's own (P2).
+const kept = (d) => row(d, 12).review.rounds.at(-1).issue;
test("review issue, lead decision 23: none refuses, one is used, several need --issue, later rounds keep it", () => {
// No issues: refused before the round opens.
refused(() => review(row12Started([])), /row 12 lists no issues; a privileged actor sets one before review/);
// One issue: used without --issue; --issue may name it; any other refuses.
const one = review(row12Started([1508]));
- assert.equal(row(one, 12).review.issue, 1508);
+ assert.equal(kept(one), 1508);
assert.match(one.log.at(-1).result.receipt, /round 1 on #1508$/);
assert.equal(one.log.at(-1).result.issue, 1508);
refused(() => review(row12Started([1508]), { issue: 1495 }), /--issue #1495 is not one of row 12's issues \(#1508\)/);
@@ -225,25 +227,36 @@ test("review issue, lead decision 23: none refuses, one is used, several need --
refused(() => review(several), /row 12 lists several issues \(#1495, #1508\); name the review's issue with --issue/);
refused(() => review(several, { issue: 1600 }), /--issue #1600 is not one of row 12's issues \(#1495, #1508\)/);
let d = review(several, { issue: 1508 });
- assert.equal(row(d, 12).review.issue, 1508);
+ assert.equal(kept(d), 1508);
// Later rounds keep the previous round's issue unless --issue names another.
d = review(again(d));
- assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1508, 2]);
+ assert.deepEqual([kept(d), row(d, 12).review.rounds.length], [1508, 2]);
d = review(again(d), { issue: 1495 });
- assert.deepEqual([row(d, 12).review.issue, row(d, 12).review.rounds.length], [1495, 3]);
+ assert.deepEqual([kept(d), row(d, 12).review.rounds.length], [1495, 3]);
+ // P2: each round keeps the issue it posted to; a later --issue rewrites none.
+ assert.deepEqual(row(d, 12).review.rounds.map((r) => [r.n, r.issue]), [[1, 1508], [2, 1508], [3, 1495]]);
+ d = review(again(d));
+ assert.deepEqual(row(d, 12).review.rounds.map((r) => r.issue), [1508, 1508, 1495, 1495]);
// A kept issue the row no longer lists refuses until --issue names one.
d = step(again(d), "set", { id: 12, field: "issues", value: [1508, 1600] }, "sage");
refused(() => review(d), /review issue #1495 is no longer one of its issues \(#1508, #1600\); name one with --issue/);
- assert.equal(row(review(d, { issue: 1600 }), 12).review.issue, 1600);
+ assert.equal(kept(review(d, { issue: 1600 })), 1600);
// --issue belongs to the review request only.
refused(() => step(several, "move", mv(12, "blocked", { reason: "x", issue: 1508 }), "darkwing"), /--issue applies only to in-progress→in-review/);
});
-test("the row schema refuses a review with a null issue", () => {
+test("the row schema refuses a round with a null issue, and the A1 review shape (P2)", () => {
const r = structuredClone(row(review(row12Started([1508])), 12));
validateRow(r);
- r.review.issue = null;
- refused(() => validateRow(r), /review issue must be a positive integer/);
+ const bad = structuredClone(r);
+ bad.review.rounds[0].issue = null;
+ refused(() => validateRow(bad), /review round issue must be a positive integer/);
+ const noIssue = structuredClone(r);
+ delete noIssue.review.rounds[0].issue;
+ refused(() => validateRow(noIssue), /review round keys must be exactly n, op, by, at, issue, candidate, request/);
+ const a1 = structuredClone(r);
+ a1.review = { issue: 1508, rounds: a1.review.rounds };
+ refused(() => validateRow(a1), /review keys must be exactly rounds in that order/);
});
// R1: every state × target × actor class against 8.7's table, written from
@@ -392,7 +405,7 @@ test("field edits: who may change what", () => {
refused(() => step(d, "set", set(9, "closes", [1]), "sage", {}), /--reason|not one of/);
refused(() => step(d, "set", set(9, "closes", [1], "r"), "sage"), /not one of row 9's issues/);
const issues = step(narrowed, "set", set(9, "issues", [1508, 1510]), "sage");
- assert.deepEqual(row(issues, 9).closes, [1508, 1510]);
+ assert.deepEqual(row(issues, 9).closes, []);
refused(() => step(d, "set", set(9, "piece", "Queue as data"), "sage"), /already/);
refused(() => step(d, "set", set(1, "piece", "x"), "jason"), /done rows never change/);
const repin = step(d, "set", set(9, "brief", "docs/plans/brief-b.md#Queue"), "sage", { brief: brief(undefined, undefined, BLOB2) });
@@ -401,6 +414,123 @@ test("field edits: who may change what", () => {
refused(() => step(d, "set", set(9, "brief", "docs/plans/brief-b.md#Queue"), "darkwing", { brief: brief() }), /privileged/);
});
+test("set issues keeps a logged narrowing of closes (N10)", () => {
+ const set = (id, field, value, reason = null) => ({ id, field, value, reason });
+ const last = (d) => d.log.at(-1).result;
+ // Not narrowed: closes follows the issues.
+ const wide = step(genesisDoc(), "set", set(9, "issues", [1508, 1510]), "sage");
+ assert.deepEqual(row(wide, 9).closes, [1508, 1510]);
+ assert.deepEqual(last(wide).closes, { from: [1508], to: [1508, 1510], narrowed: false });
+ assert.match(last(wide).receipt, /row 9 issues: #1508→#1508, #1510; closes: #1508→#1508, #1510$/);
+ // Narrowed: closes keeps its intersection with the new issues.
+ const narrowed = step(wide, "set", set(9, "closes", [1510], "1508 closes elsewhere"), "sage");
+ const more = step(narrowed, "set", set(9, "issues", [1508, 1510, 1600]), "sage");
+ assert.deepEqual(row(more, 9).closes, [1510]);
+ assert.deepEqual(last(more).closes, { from: [1510], to: [1510], narrowed: true });
+ assert.match(last(more).receipt, /; closes: #1510→#1510 \(kept narrowed\)$/);
+ const fewer = step(more, "set", set(9, "issues", [1508, 1600]), "sage");
+ assert.deepEqual(row(fewer, 9).closes, []);
+ assert.match(last(fewer).receipt, /; closes: #1510→none \(kept narrowed\)$/);
+ refused(() => step(narrowed, "set", set(9, "issues", [1508, 1510]), "sage"), /row 9 issues is already/);
+ // The results replay, and other fields carry no closes.
+ loadDoc(Buffer.from(serialize(fewer)));
+ assert.equal("closes" in last(step(genesisDoc(), "set", set(11, "piece", "Renamed"), "sage")), false);
+});
+
+// GFM's cell split: a backslash escapes the next character, and a pipe not
+// escaped separates cells. The outer pipes are dropped first.
+function gfmCells(line) {
+ const inner = line.trim().replace(/^\|/, "");
+ const cells = [""];
+ for (let i = 0; i < inner.length; i++) {
+ if (inner[i] === "\\" && i + 1 < inner.length) { cells[cells.length - 1] += inner[i] + inner[i + 1]; i++; continue; }
+ if (inner[i] === "|") cells.push("");
+ else cells[cells.length - 1] += inner[i];
+ }
+ if (cells.at(-1).trim() === "") cells.pop();
+ return cells.length;
+}
+
+test("text the table shows refuses \\ and <, everywhere it enters (N8)", () => {
+ const d = genesisDoc();
+ const bad = ["a\\| done | x", "back\\slash", "<b>bold</b>", "a < b"];
+ const set = (field, value) => ({ id: 11, field, value, reason: null });
+ for (const text of bad) {
+ const re = /must not contain \\ or </;
+ refused(() => canonArgs("add", { piece: text, gate: "g", brief: "docs/plans/brief-b.md#Queue" }), re);
+ refused(() => canonArgs("add", { piece: "p", gate: text, brief: "docs/plans/brief-b.md#Queue" }), re);
+ refused(() => canonArgs("add", { piece: "p", gate: "g", note: text, brief: "docs/plans/brief-b.md#Queue" }), re);
+ refused(() => canonArgs("add", { piece: "p", gate: "g", brief: `docs/plans/brief-b.md#${text}` }), re);
+ refused(() => canonArgs("move", mv(9, "blocked", { reason: text })), re);
+ refused(() => canonArgs("note", { id: 9, text }), re);
+ refused(() => canonArgs("set", set("piece", text)), re);
+ refused(() => canonArgs("set", set("gate", text)), re);
+ refused(() => canonArgs("set", set("brief", `docs/plans/brief-b.md#${text}`)), re);
+ // A hand-built row or log entry is refused the same way.
+ const r = row(d, 9);
+ for (const [k, v] of [["piece", text], ["gate", text], ["note", text], ["brief", { ...r.brief, anchor: text }]]) {
+ refused(() => validateRow({ ...r, [k]: v }), re);
+ }
+ refused(() => validateRow({ ...r, state: "blocked", previousState: "briefed", blockedReason: text }), re);
+ refused(() => loadDoc(Buffer.from(serialize(genesisDoc(MAP_ROWS.map((m) => (m.id === 9 ? { ...m, piece: text } : m)))))), re);
+ }
+});
+
+test("every accepted text renders to nine cells on every row (N8)", () => {
+ const base = row(genesisDoc(), 9);
+ const chars = [];
+ for (let c = 0x20; c < 0x7f; c++) chars.push(String.fromCharCode(c));
+ chars.push("é", "§", "—", String.fromCharCode(0xa0), "\u{1f600}");
+ const texts = ["a\\| done | x", "|", "||", "`|`", "a | b", "x\\", "\\|\\|", "<!-- x -->", "&#124;"];
+ for (const c of chars) texts.push(`x${c}y`, `x${c}|${c}y`, `x${c}${c}|`);
+ const fields = [
+ (t) => ({ piece: t }), (t) => ({ gate: t }), (t) => ({ note: t }), (t) => ({ brief: { ...base.brief, anchor: t } }),
+ (t) => ({ state: "blocked", previousState: "briefed", blockedReason: t }),
+ ];
+ const accepted = new Set();
+ for (const t of texts) {
+ for (const f of fields) {
+ const r = { ...base, ...f(t) };
+ try { validateRow(r); } catch (err) { if (err instanceof QueueError) continue; throw err; }
+ accepted.add(t);
+ const lines = render([r], 1).split("\n").filter((l) => l.startsWith("|"));
+ for (const l of lines) assert.equal(gfmCells(l), 9, `${JSON.stringify(t)} renders ${JSON.stringify(l)}`);
+ }
+ }
+ // Not vacuous: pipes and backticks pass; the two refused characters don't.
+ for (const t of ["|", "a | b", "`|`", "x|y"]) assert.ok(accepted.has(t), t);
+ for (const t of ["a\\| done | x", "x<y", "x\\y"]) assert.ok(!accepted.has(t), t);
+});
+
+test("replay holds every op id to the caller's rule (N11)", () => {
+ const load = (d) => loadDoc(Buffer.from(serialize(d)));
+ const d = genesisDoc();
+ // 72 characters replay; 73 to 80 no longer do.
+ load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(72)));
+ for (const n of [73, 80]) {
+ refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(n))), /log entry 1 op .* must match/);
+ }
+ // .outcome is reserved on every verb, accept-history included.
+ refused(() => load(step(d, "accept-history", { reason: "r" }, "sage", { oldWitness: null }, "accept-history-1.outcome")), /log entry 1 op .* ends in \.outcome/);
+ refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "note-row9.outcome")), /\.outcome, which is reserved/);
+ // The genesis op is checked too.
+ load(genesisDoc(MAP_ROWS, "genesis-ok-0001"));
+ for (const op of ["short", "Genesis-op-0001", "genesis-op-0001.outcome", "g".repeat(73)]) {
+ refused(() => load(genesisDoc(MAP_ROWS, op)), /log entry 0 op/);
+ }
+ // The ops a row carries, a claim and a review round, follow the same rule.
+ const claimed = structuredClone(row(row9Started(), 9));
+ const reviewed = structuredClone(row(review(row12Started([1508])), 12));
+ validateRow(claimed);
+ validateRow(reviewed);
+ for (const op of ["a".repeat(73), "start-row9.outcome"]) {
+ refused(() => validateRow({ ...claimed, claim: { ...claimed.claim, op } }), /claim op/);
+ const r = structuredClone(reviewed);
+ r.review.rounds[0].op = op;
+ refused(() => validateRow(r), /review round op/);
+ }
+});
+
test("note: owner, listed reviewer or privileged; empty clears", () => {
const d = genesisDoc();
const n = step(d, "note", { id: 9, text: "from the reviewer" }, "filbert");
diff --git a/packages/queue/tests/dispatch.test.mjs b/packages/queue/tests/dispatch.test.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..aa425c0b1692803337b3654a5f38d524f3119760
--- /dev/null
+++ b/packages/queue/tests/dispatch.test.mjs
@@ -0,0 +1,100 @@
+// `scripts/mosaic queue` (A2). The dispatch must leave every other call to
+// scripts/mosaic exactly as it was: the seat CLI sees the same argv, working
+// directory, environment and stdin under the new script as under the pre-A2
+// copy in fixtures/mosaic-pre-a2.sh. Both CLIs are fakes that record what
+// they got; nothing here runs a seat or touches a queue.
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { test } from "node:test";
+import { SCRIPTS } from "./helpers.mjs";
+
+const HERE = new URL(".", import.meta.url).pathname;
+
+const FAKE = `import { readFileSync, writeFileSync } from "node:fs";
+writeFileSync(process.env.CAPTURE, JSON.stringify({
+ cli: process.argv[1].slice(process.env.ROOT.length), argv: process.argv.slice(2), cwd: process.cwd(),
+ env: process.env, stdin: readFileSync(0, "utf8"),
+}));
+process.stdout.write("fake out\\n");
+process.stderr.write("fake err\\n");
+process.exitCode = Number(process.env.EXIT);
+`;
+
+// A root holding `script` as scripts/mosaic and both fake CLIs.
+function root(t, script) {
+ const r = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-queue-dispatch-")));
+ t.after(() => rmSync(r, { recursive: true, force: true }));
+ mkdirSync(join(r, "scripts"));
+ cpSync(script, join(r, "scripts/mosaic"));
+ for (const pkg of ["seat", "queue"]) {
+ mkdirSync(join(r, `packages/${pkg}/src`), { recursive: true });
+ writeFileSync(join(r, `packages/${pkg}/src/cli.mjs`), FAKE);
+ }
+ mkdirSync(join(r, "work"));
+ return r;
+}
+
+// Runs scripts/mosaic from `work` and returns everything the fake saw, with
+// the root replaced so two roots compare.
+function call(r, args, exit = 7) {
+ const capture = join(r, "capture.json");
+ rmSync(capture, { force: true });
+ const env = { PATH: process.env.PATH, HOME: r, LANG: "C", ROOT: r, CAPTURE: capture, EXIT: String(exit), MOSAIC_AGENT_NAME: "darkwing", SPACED: "a b" };
+ const p = spawnSync("bash", [join(r, "scripts/mosaic"), ...args], { cwd: join(r, "work"), env, input: "stdin line\nsecond\n", encoding: "utf8" });
+ if (p.error) throw p.error;
+ const got = existsSync(capture) ? readFileSync(capture, "utf8") : null;
+ return { status: p.status, out: p.stdout, err: p.stderr, capture: got === null ? null : got.replaceAll(r, "<ROOT>") };
+}
+
+const SEAT_CALLS = [
+ ["launch", "--repo", "/srv/repo", "--harness", "pi", "darkwing"],
+ ["seat", "task", "darkwing", "text with two spaces", ""],
+ [],
+ [""],
+ ["launch", "queue"],
+ ["Queue", "list"],
+ ["queue-x"],
+ ["--", "queue"],
+ ["--help"],
+];
+
+test("every call but `queue` reaches the seat CLI exactly as before A2", (t) => {
+ const before = root(t, join(HERE, "fixtures", "mosaic-pre-a2.sh"));
+ const after = root(t, join(SCRIPTS, "mosaic"));
+ for (const args of SEAT_CALLS) {
+ const a = call(before, args);
+ const b = call(after, args);
+ assert.notEqual(a.capture, null, `${JSON.stringify(args)}: the pre-A2 script reached no CLI`);
+ assert.equal(JSON.parse(a.capture).cli, "/packages/seat/src/cli.mjs");
+ assert.deepEqual(b, a, JSON.stringify(args));
+ }
+});
+
+test("`queue` reaches the queue CLI with the rest of the arguments", (t) => {
+ const r = root(t, join(SCRIPTS, "mosaic"));
+ for (const rest of [["list"], [], ["note", "9", "text with spaces", "--op", "note-9-000001"], ["queue"], [""]]) {
+ const got = call(r, ["queue", ...rest]);
+ assert.deepEqual([got.status, got.out, got.err], [7, "fake out\n", "fake err\n"]);
+ const c = JSON.parse(got.capture);
+ assert.deepEqual([c.cli, c.argv, c.cwd, c.stdin], ["/packages/queue/src/cli.mjs", rest, "<ROOT>/work", "stdin line\nsecond\n"]);
+ }
+ // A queue CLI that succeeds must not fall through to the seat CLI.
+ const ok = call(r, ["queue", "list"], 0);
+ assert.deepEqual([ok.status, ok.out, JSON.parse(ok.capture).cli], [0, "fake out\n", "/packages/queue/src/cli.mjs"]);
+});
+
+test("the pre-A2 fixture is the script A2 changed", () => {
+ const fixture = readFileSync(join(HERE, "fixtures", "mosaic-pre-a2.sh"), "utf8");
+ const now = readFileSync(join(SCRIPTS, "mosaic"), "utf8");
+ // A2 adds lines and removes none.
+ const lines = now.split("\n");
+ let i = 0;
+ for (const l of fixture.split("\n")) {
+ while (i < lines.length && lines[i] !== l) i++;
+ assert.ok(i < lines.length, `pre-A2 line missing: ${l}`);
+ i++;
+ }
+});
diff --git a/packages/queue/tests/fixtures/genesis-render.md b/packages/queue/tests/fixtures/genesis-render.md
new file mode 100644
index 0000000000000000000000000000000000000000..244a34189007c035b5b0126ffb95491878da1385
--- /dev/null
+++ b/packages/queue/tests/fixtures/genesis-render.md
@@ -0,0 +1,36 @@
+
+Generated from `docs/plans/queue.json` revision 0 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
+
+| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
+|---|---|---|---|---|---|---|---|---|
+| 1 | Control board MVP (scanner + page) | darkwing | #1503 | done | — | A passed 2026-09-12 (jason) | `docs/plans/2026-09-12_control-board-mvp.md` § Control board MVP — plan | done 2026-09-27 (Sage, lead decision 29): D-001 MVP delivered, Gate A passed 2026-09-12, attention status operator-accepted (row 22), relaunch activity, attribution and Host guard landed; #1503 closed; cross-harness board work belongs to Gate E (row 5) |
+| 2 | Seat registration and `mosaic seat task` | darkwing | #1504 | done | — | B passed 2026-09-12 (jason) | `docs/plans/2026-09-12_control-board-mvp.md` § Step 3: daily use and fixes | done 2026-09-13 |
+| 3 | Reply-from-board (piece 2 on the plan page) | darkwing | #1505 | done | — | C passed 2026-09-12 (jason) | `docs/plans/2026-09-12_control-board-mvp.md` § Piece 2: reply-from-board | done 2026-09-12 |
+| 4 | Ledger (piece 3 on the plan page) | darkwing | #1506 | done | — | D: 18.9 human messages per closed issue, week of 2026-09-06 (jason) | `docs/plans/2026-09-12_control-board-mvp.md` § Piece 3: ledger (numbers for the rails) | done 2026-09-13 |
+| 5 | WebUI first screen on the Console design (piece 4 on the plan page) | dewey; reviewers filbert, rocko | #1507 | in-progress | — | E: all-seat interactive demonstration then Jason workday ruling; live cutover separately approved (jason) | `docs/plans/2026-09-13_webui-session-chat.md` § WebUI session chat: refined brief and delivery plan | CHAT-00/01/01C published (370823b3, 28d4e98a, b023841c); CHAT-02 done: backend a5beb6d9, Console c9e771cf, live check passed 2026-09-26; CHAT-03 brief approved and pinned 2026-09-27 (BRIEF.md 1ef15ac0, rescoped to Gate E, lead decisions 30 to 33; Filbert r3 approve, Rocko blocker closed, Filbert scope check pass); source author named by Sage after queue A2 lands; CHAT-04..08 not chartered; Gate E blocked |
+| 6 | Darkwing on point: darkwing assigns and gates filbert's work (piece 5 on the plan page) | darkwing; reviewers dewey | #1511, #1512 | done | — | F: filbert's item closes with zero human messages from Jason; code phase does not claim Gate F (jason) | `docs/plans/2026-09-12_control-board-mvp.md` § Piece 5: darkwing on point (orchestrator behaviour) | done 2026-09-27 (lead decision 29): #1511 and #1512 landed in af4203ca (pushed), closed; Gate F not passed (2 human messages in filbert's T3 thread; the first 11 days predate the T3 source); Gate G (row 9) carries the test. Filbert authored the pilot. Briefs also: 2026-09-15_relaunch-activity.md, 2026-09-14_task-attribution.md |
+| 7 | Weekly ledger run and rails number | sage | — | in-progress | — | under 10 human messages per closed issue for the week of 2026-09-13 (jason) | `packages/ledger/README.md` § Ledger | recurring, every Monday (lead decision 27); Jason reads; 09-13..19: 47.7, 09-20..26: 33.0 human messages per closed issue |
+| 8 | Fleet seats (`~/.mosaic`) onto `mosaic launch` | unassigned | — | parked | — | Jason's call (jason) | `docs/plans/2026-09-26_fleet-seats-onto-mosaic-launch.md` § Row 8: fleet seats onto `mosaic launch` (stub brief) | parked by current owner direction; no `~/.mosaic` changes during internal bootstrap |
+| 9 | Queue as data: `docs/plans/queue.json`, `mosaic queue` is the only writer | darkwing; reviewers filbert | #1508; closes none | required; in-progress | — | G: a fresh seat told only "run `mosaic queue next` and do it" starts its piece with zero human messages (jason) | `docs/plans/2026-09-13_queue-as-data.md` § Piece A: queue record and `mosaic queue` (QUEUE row 9) | A1 (journal, lock, CLI, verify) committed 34a72af9; A2 (migration, render, dispatch) committed with this map; Filbert approved both; genesis and hook install by Sage; Gate G also verifies row 10 |
+| 10 | Seats read the queue, not CURRENT.md (AGENTS.md cadence, seat context files) | coordinator | #1508; closes none | required; briefed | — | verified by Gate G (jason) | `docs/plans/2026-09-13_queue-as-data.md` § Piece B: seats read the queue, not CURRENT.md (QUEUE row 10) | AGENTS.md lines done 2026-09-13; the rest starts once genesis is committed |
+| 11 | Brief template `docs/plans/BRIEF-TEMPLATE.md`; `queue add` refuses a missing brief | darkwing | #1508; closes none | required; waiting-on-jason | — | two briefs accepted by Jason with no scope question (jason) | `docs/plans/2026-09-13_queue-as-data.md` § Piece C: brief template (QUEUE row 11) | template and the `queue add` brief refusal both committed with A1 (34a72af9); the gate counts the first briefs added through the queue |
+| 12 | Reviews as issue comments posted by `queue move ID in-review`, not files in `docs/plans/reviews/` | darkwing | #1508; closes none | required; briefed | — | one review round with no new file under reviews/ (sage) | `docs/plans/2026-09-13_queue-as-data.md` § Piece D: reviews through a channel, not files (QUEUE row 12) | starts once genesis is committed; the live posting test needs the per-seat credential file (8.9) |
+| 13 | Ledger "queue" section: issue/row/seat drift printed with the weekly number | darkwing; reviewers filbert | #1508 | required; briefed | — | first run Monday 2026-09-21, zero violations or every one moved same day (sage) | `docs/plans/2026-09-13_queue-as-data.md` § Piece E: ledger checks the queue (QUEUE row 13) | starts once genesis is committed; closes #1508 when done, so rows 9 to 12 close nothing; first run is the Monday after E is approved (Q7) |
+| 14 | Discord connector pilot: Sage answers in Shared Signals (chat only, no tools, no repo writes) | coordinator; reviewers filbert | #1509 | done | — | H: offline suite green, eight-step live pilot with private receipts, then Jason says the reply reads as Sage (jason) | `docs/plans/2026-09-13_discord-connector-pilot.md` § Discord connector pilot: Sage on Shared Signals | done: rev-code-02 APPROVE 26170 (round 9), committed 786e379c; pilot steps 1-8 done with private receipts, Gate H passed (Jason, 2026-09-13: replies read as Sage); connector left running for MVP iteration; reviewer lane was Filbert or orch-01 |
+| 15 | Discord connector: eyes reaction on every admitted message as a read receipt (MVP iteration 1) | coordinator | #1509 | done | — | Jason sees the reaction on a live message (jason) | `docs/plans/2026-09-13_discord-connector-pilot.md` § 11. MVP iteration, after the pilot | done: committed 93d6b624, live check passed 19:21 UTC (turn record receipt ok, Jason: test is successful), receipt `mvp1-read-receipt-20260913T192158Z.json` in the private evidence dir; reaction placed at admission before the engine runs, outcome in the turn record, none on drops or refusals; `scripts/test-discord.sh` 28/28 (90 node tests) |
+| 16 | Repository-native development bootstrap: five internal agents, Darkwing coordination | darkwing; reviewers filbert | #1510 | waiting-on-jason | — | source approved; Researcher live response passed 26216; existing native Rocko lock preserved, no new Rocko model test; no MVP acceptance/publication (jason) | `docs/plans/2026-09-13_internal-development-bootstrap.md` § Repository-native development bootstrap | source approved by internal Filbert, exact R1 receipt 26204; six offline tests independently pass in both copies; 24 config tests and five no-effect checks are author evidence |
+| 17 | Discord connector: systemd user service with a supervised pre-start (`recover`, exit 3 never retried) (MVP iteration 2) | coordinator | #1509 | done | — | the Sage connector runs under `mosaic-discord@shared-signals`, survives a kill with a clean restart, and stays down behind `discord.sh stop` (jason) | `docs/plans/2026-09-13_discord-connector-pilot.md` § 11. MVP iteration, after the pilot | done, operator-verified by Jason 2026-09-13 (all four steps): suite 40/40 (95 node tests); Sage seat migrated 19:35 UTC, SIGKILL recovered in 16 s with the dead lock cleared, brake held (exit 3, no restart), released and READY; receipt `mvp2-service-unit-*.json` in the private evidence dir; the first cut (ExecStartPre) looped and was replaced by `run --supervised` before any traffic |
+| 18 | Control board row for the Discord connector (MVP iteration 3): discovery from binding files, liveness from run.lock, reply refused | darkwing | #1509 | done | — | a Sage (discord) row on the board shows live, offline and braked correctly, and reply from the board is refused (jason) | `docs/plans/2026-09-14_discord-board-row.md` § Discord connector board row | done for bounded local delivery; Jason accepted visual test (parfait), R3 26257 reviewed, 322 tests and live 409 refusal verified; owner by Jason's ruling 2026-09-13, coordinator answered connector-side questions; also briefed in the pilot page section 11 |
+| 19 | Discord connector: binding reload without a restart (`reload` verb, SIGHUP, `systemctl --user reload`); channels, users, limits and guildName apply in place, identity, engine and context stay fixed, an invalid file is refused and the old binding kept (MVP iteration 4) | coordinator | #1509 | done | — | edit the binding, run `scripts/discord.sh reload shared-signals`, the change applies with no restart, a broken edit is refused and journaled (jason) | `docs/plans/2026-09-13_discord-connector-pilot.md` § 11. MVP iteration, after the pilot | done: caaef941; live 00:03 UTC: reload applied Carmen's entry with no restart, unknown key refused by the CLI (exit 2), fixed key refused in the process with the binding kept, `systemctl --user reload` applied; suite 41/41 (101 node tests); receipt `mvp4-5-reload-carmen-*.json` |
+| 20 | Discord connector: per-user channel allowlist in the binding and Carmen enrolled (all listed rooms except #sage-admin) (MVP iteration 5) | coordinator | #1509 | done | — | Carmen gets a reply in #general and silence in #sage-admin; Jason unchanged (jason) | `docs/plans/2026-09-13_discord-connector-pilot.md` § 11. MVP iteration, after the pilot | done: caaef941 (`users[].channels` allowlist, `channel-not-for-user` drop); Carmen enrolled live by reload 00:03 UTC; her first message was the remaining check; #1509 closed with rows 14 to 25 done (lead decision 29) |
+| 21 | Discord connector: read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (MVP iteration 6) | coordinator | #1509 | done | — | Sage answers a question from a file under a declared root with the reads in the turn record; a read outside the roots is refused and recorded (jason) | `docs/plans/2026-09-14_discord-readonly-tools.md` § Discord Sage: read-only tools (iteration 6) | approved: rev-code-02 round 2 verdict 26276 (tree 43f0329b), committed; Jason ruled R1–R7 2026-09-14 (roots docs/ and agents/sage/, Carmen included); done with rows 14 to 25 when #1509 closed (lead decision 29); reviewer per Q12 |
+| 22 | Board attention status: completed replies idle, explicit human input waiting | darkwing; reviewers filbert | #1503 | done | — | operator accepted full targeted status sequence: explicit request waiting, Seen hides attention but preserves waiting, completion returns idle; broader MVP/cross-harness acceptance and publication separate (jason) | `docs/plans/2026-09-13_board-attention-status.md` § Board attention status correction | source approved, receipt 26248; reviewer 193 per copy, author union 213 pass; actual Researcher scan idle; operator accepted (row 1); #1503 closed (lead decision 29) |
+| 23 | Discord connector: writes confined to the `shared-signals` root plus web fetch and search for the Discord Sage (MVP iteration 7) | coordinator | #1509 | done | — | Sage writes a naming shortlist into the repository from #ideas with the write and web calls in the turn record; a write outside the root is refused (jason) | `docs/plans/2026-09-16_discord-write-and-web-tools.md` § Discord Sage: writes into the strategy repository, and web research (#1509, QUEUE row 23) | done 2026-09-27 (Sage, lead decision 29): committed and pushed 1685deb4; live turns wrote `vault/Businesses/naming.md` with web_search and web_fetch in the turn record; the outside-root refusal rests on the offline suite, not a live turn; reviewer per Q12 |
+| 24 | Discord connector: git verbs (status, commit, pull ff-only, push) for the Discord Sage on the `shared-signals` root, seat identity through the existing credential helper (MVP iteration 8) | coordinator | #1509 | done | — | Sage commits and pushes a decision file from #ideas; GitHub shows Sage as author with a Requested-by trailer; no token in any record (jason) | `docs/plans/2026-09-16_discord-git-tools.md` § Discord Sage: git for the strategy repository (#1509, QUEUE row 24) | done 2026-09-27 (Sage, lead decision 29): committed and pushed 1949ed8d; live-configured 2026-09-18, never used from Discord; records now go through row 25 and SetSpark cutover freezes `vault/`, so first real use is the check and a failure opens a new issue; reviewer per Q12 |
+| 25 | Discord connector: SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (MVP iteration 9) | coordinator | #1509 | done | — | Sage creates one work item from #sage-admin and the API shows it with revision 1; a proposal is approved by the Approve button and the audit row carries Sage's key id and Jason's Discord id separately; rev-code-02 approves on #1509 (jason) | `docs/plans/2026-09-20_discord-setspark-client.md` § Discord Sage: SetSpark record client (#1509, QUEUE row 25) | done 2026-09-26: committed and pushed 43d7574d, live check passed (DEC-010, request 2, approved by button at 21:38:58Z, lead decision 19); approver rule enforced by the service since shared-signals cc74d92 (lead decisions 24 and 28); reviewer per Q12 |
+| 26 | Registry increment 3 (headless identity-env leak) | unassigned | — | parked | — | Jason reopens (jason) | `docs/plans/CURRENT.md` § Completed checkpoint: #1500 increment 2 (historical) | #1500 closed on increment 2; Jason has not asked for 3 |
+| 27 | Auth/provider/harness registry review | unassigned | — | parked | — | Jason reopens (jason) | `docs/plans/2026-09-03_auth-provider-harness-registry.md` § Harness declaration + centralized auth/provider registry | Paused for owner alignment |
+| 28 | CI runners, second real adapter, push automation | unassigned | — | parked | — | Jason reopens (jason) | `docs/plans/ROADMAP.md` § Explicitly deferred | Deferred by owner 2026-09-03 |
+| 29 | Console features outside the refined session-chat brief, including Fresh creation and model switching | unassigned | — | parked | — | Jason reopens (jason) | `docs/plans/2026-09-13_webui-session-chat.md` § WebUI session chat: refined brief and delivery plan | Deferred by WEBUI Q1; required history/control/stop now belong to row 5, not this parked item |
+| 30 | Open gaps from the MVP work | unassigned | — | parked | — | Jason reopens (jason) | `docs/plans/DEFERRED.md` § Open | Fixed only when a gate needs them |
+
diff --git a/packages/queue/tests/fixtures/mosaic-pre-a2.sh b/packages/queue/tests/fixtures/mosaic-pre-a2.sh
new file mode 100644
index 0000000000000000000000000000000000000000..0a50fdb74d7a9b7aa2e82a36ee5b067f654b058d
--- /dev/null
+++ b/packages/queue/tests/fixtures/mosaic-pre-a2.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# `mosaic launch <seat>` and `mosaic seat task <seat> <text>`: seat launch
+# with registration for the control board. See packages/seat/README.md.
+# Not the npm-global `mosaic` CLI from the estate tooling; this one is
+# repository-local and only reachable as scripts/mosaic.
+set -euo pipefail
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+exec node "$REPO/packages/seat/src/cli.mjs" "$@"
diff --git a/packages/queue/tests/fixtures/queue-marked.md b/packages/queue/tests/fixtures/queue-marked.md
new file mode 100644
index 0000000000000000000000000000000000000000..dd155c660a53de25f5cf1985ad37676da140ed2d
--- /dev/null
+++ b/packages/queue/tests/fixtures/queue-marked.md
@@ -0,0 +1,157 @@
+# QUEUE — the one task list
+
+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:
+
+- **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` (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.
+
+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.
+
+Current priority: Sage's goal order from the 2026-09-27 goals review (close
+what is open, queue as data, Gate E), ratified by Jason 2026-09-27. Earlier: row 6, after Jason accepted row 18. His newer standing continuation instruction supplies the
+start; the earlier row-18-only priority is complete. Jason requires
+automatic continuation to the next authorized item after each accepted iteration;
+no routine next-step prompt. Explicit dependencies and protected-action gates remain. Use the five internal agents, not fleet seats. No
+`~/.mosaic` changes or live fleet migration. Row 6 closed 2026-09-27 (lead decision 29),
+which clears rows 9-13 to proceed; it does not authorize publication/access changes.
+
+| # | Piece | Owner | Issue | State | Gate | Brief |
+|---|-------|-------|-------|-------|------|-------|
+| 1 | Control board MVP (scanner + page) | darkwing | #1503 | done 2026-09-27 (Sage, lead decision 29): D-001 MVP delivered, Gate A passed 2026-09-12, attention status operator-accepted (row 22), relaunch activity, attribution and Host guard landed; #1503 closed; cross-harness board work belongs to Gate E (row 5) | A passed 2026-09-12 | plan page, top |
+| 2 | Seat registration and `mosaic seat task` | darkwing | #1504 | done 2026-09-13 | B passed 2026-09-12 | plan page, Step 3 |
+| 3 | Reply-from-board (piece 2 on the plan page) | darkwing | #1505 | done 2026-09-12 | C passed 2026-09-12 | plan page, "Piece 2: reply-from-board" |
+| 4 | Ledger (piece 3 on the plan page) | darkwing | #1506 | done 2026-09-13 | D: 18.9 human messages per closed issue, week of 2026-09-06 | plan page, "Piece 3: ledger" |
+| 5 | WebUI first screen on the Console design (piece 4 on the plan page) | dewey; filbert reviews | #1507 | CHAT-00/01/01C contracts published (370823b3, 28d4e98a, b023841c); CHAT-02 done: backend a5beb6d9, Console c9e771cf, live check passed 2026-09-26; CHAT-03 brief approved and pinned 2026-09-27 (BRIEF.md 1ef15ac0, rescoped to Gate E, lead decisions 30 to 33; Filbert r3 approve, Rocko blocker closed, Filbert scope check pass); source author named by Sage after queue A2 lands; CHAT-04..08 not chartered; Gate E blocked | E: all-seat interactive demonstration then Jason workday ruling; live cutover separately approved | `2026-09-13_webui-session-chat.md`, CHAT-00..08 |
+| 6 | Darkwing on point: darkwing assigns and gates filbert's work (piece 5 on the plan page) | darkwing; filbert authors pilot; darkwing/dewey review | #1511 code phase; #1512 pilot | done 2026-09-27 (Sage, lead decision 29): #1511 and #1512 landed in af4203ca (pushed) and are closed; Gate F not passed: the ledger counts 2 human messages in filbert's T3 thread during #1512 (one is Jason's 09-26 seat takeover) and its first 11 days predate the T3 source; Gate G (row 9) now carries the zero-human-message test | F: filbert's item closes with zero human messages from Jason; code phase does not claim Gate F | `2026-09-15_relaunch-activity.md`; `2026-09-14_task-attribution.md`; plan page Piece 5 |
+| 7 | Weekly ledger run and rails number | sage (lead decision 27); Jason reads | — | recurring, every Monday; 09-13..19: 47.7, 09-20..26: 33.0 human messages per closed issue | under 10 human messages per closed issue for the week of 2026-09-13 | `packages/ledger/README.md` |
+| 8 | Fleet seats (`~/.mosaic`) onto `mosaic launch` | unassigned | — | parked by current owner direction; no `~/.mosaic` changes during internal bootstrap | Jason's call | none yet |
+| 9 | Queue as data: `docs/plans/queue.json`, `mosaic queue` is the only writer | darkwing; filbert reviews | #1508 | required; A1 (journal, lock, CLI, verify) committed 34a72af9 2026-09-27, Filbert approved; A2 (migration, render, dispatch) in progress, then genesis and hook install by Sage | G: a fresh seat told only "run `mosaic queue next` and do it" starts its piece with zero human messages | `2026-09-13_queue-as-data.md`, Piece A |
+| 10 | Seats read the queue, not CURRENT.md (AGENTS.md cadence, seat context files) | coordinator | #1508 | required; AGENTS.md lines done 2026-09-13, rest after row 9 | verified by Gate G | `2026-09-13_queue-as-data.md`, Piece B |
+| 11 | Brief template `docs/plans/BRIEF-TEMPLATE.md`; `queue add` refuses a missing brief | darkwing | #1508 | required; template committed with A1 (34a72af9); `queue add` refusal lands with A2 | two briefs accepted by Jason with no scope question | `2026-09-13_queue-as-data.md`, Piece C |
+| 12 | Reviews as issue comments posted by `queue move ID in-review`, not files in `docs/plans/reviews/` | darkwing | #1508 | required; after row 9 | one review round with no new file under reviews/ | `2026-09-13_queue-as-data.md`, Piece D |
+| 13 | Ledger "queue" section: issue/row/seat drift printed with the weekly number | darkwing; filbert reviews | #1508 | required; after row 9 | first run Monday 2026-09-21, zero violations or every one moved same day | `2026-09-13_queue-as-data.md`, Piece E |
+| 14 | Discord connector pilot: Sage answers in Shared Signals (chat only, no tools, no repo writes) | coordinator; Filbert or orch-01 seat reviews | #1509 | done: rev-code-02 APPROVE 26170 (round 9), committed 786e379c; pilot steps 1-8 done with private receipts, Gate H passed (Jason, 2026-09-13: replies read as Sage); connector left running for MVP iteration | H: offline suite green, eight-step live pilot with private receipts, then Jason says the reply reads as Sage | `2026-09-13_discord-connector-pilot.md` |
+| 15 | Discord connector: eyes reaction on every admitted message as a read receipt (MVP iteration 1) | coordinator | #1509 | done: committed 93d6b624, live check passed 19:21 UTC (turn record receipt ok, Jason: test is successful), receipt `mvp1-read-receipt-20260913T192158Z.json` in the private evidence dir; `rest.react` best effort, reaction placed at admission before the engine runs, outcome in the turn record, no reaction on drops or refusals; `scripts/test-discord.sh` 28/28 (90 node tests) | Jason sees the reaction on a live message | `2026-09-13_discord-connector-pilot.md` section 11 |
+| 16 | Repository-native development bootstrap: five internal agents, Darkwing coordination | darkwing; filbert reviews | #1510 | source approved by internal Filbert, exact R1 receipt 26204; six offline tests independently pass in both copies; 24 config tests and five no-effect checks are author evidence | source approved; Researcher live response passed 26216; existing native Rocko lock preserved, no new Rocko model test; no MVP acceptance/publication | `2026-09-13_internal-development-bootstrap.md` |
+| 17 | Discord connector: systemd user service with a supervised pre-start (`recover`, exit 3 never retried) (MVP iteration 2) | coordinator | #1509 | done, operator-verified by Jason 2026-09-13 (all four steps): `scripts/test-discord.sh` 40/40 (95 node tests); Sage seat migrated 19:35 UTC, SIGKILL recovered in 16 s with the dead lock cleared, brake held (exit 3, no restart), released and READY; receipt `mvp2-service-unit-*.json` in the private evidence dir. First cut (ExecStartPre) looped and was replaced by `run --supervised` before any traffic | the Sage connector runs under `mosaic-discord@shared-signals`, survives a kill with a clean restart, and stays down behind `discord.sh stop` | `2026-09-13_discord-connector-pilot.md` section 11 |
+| 18 | Control board row for the Discord connector (MVP iteration 3): discovery from binding files, liveness from run.lock, reply refused | darkwing (Jason's ruling 2026-09-13); coordinator answers connector-side questions | #1509 | done for bounded local delivery; Jason accepted visual test (parfait), R3 26257 reviewed, 322 tests and live 409 refusal verified; no publication or live connector state transitions claimed | a Sage (discord) row on the board shows live, offline and braked correctly, and reply from the board is refused | `2026-09-14_discord-board-row.md`; original pilot section 11 |
+| 19 | Discord connector: binding reload without a restart (`reload` verb, SIGHUP, `systemctl --user reload`); channels, users, limits and guildName apply in place, identity, engine and context stay fixed, an invalid file is refused and the old binding kept (MVP iteration 4) | coordinator | #1509 | done: caaef941; live 00:03 UTC: reload applied Carmen's entry with no restart, unknown key refused by the CLI (exit 2), fixed key refused in the process with the binding kept, `systemctl --user reload` applied; suite 41/41 (101 node tests); receipt `mvp4-5-reload-carmen-*.json` | edit the binding, run `scripts/discord.sh reload shared-signals`, the change applies with no restart, a broken edit is refused and journaled | `2026-09-13_discord-connector-pilot.md` section 11 |
+| 20 | Discord connector: per-user channel allowlist in the binding and Carmen enrolled (all listed rooms except #sage-admin) (MVP iteration 5) | coordinator | #1509 | done: caaef941 (`users[].channels` allowlist, `channel-not-for-user` drop); Carmen enrolled live by reload 00:03 UTC; her first message is the remaining check | Carmen gets a reply in #general and silence in #sage-admin; Jason unchanged | `2026-09-13_discord-connector-pilot.md` section 11 |
+| 21 | Discord connector: read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (MVP iteration 6) | coordinator; reviewer per Q12 | #1509 | approved: rev-code-02 round 2 verdict 26276 (tree 43f0329b); committed locally; live check in #sage-admin with Jason next; Jason ruled R1–R7 2026-09-14 (roots docs/ and agents/sage/, Carmen included) | Sage answers a question from a file under a declared root with the reads in the turn record; a read outside the roots is refused and recorded | `2026-09-14_discord-readonly-tools.md` |
+| 22 | Board attention status: completed replies idle, explicit human input waiting | darkwing; filbert reviews | #1503 | source approved, receipt 26248; reviewer 193 per copy, author union 213 pass; actual Researcher scan idle | operator accepted full targeted status sequence: explicit request waiting, Seen hides attention but preserves waiting, completion returns idle; broader MVP/cross-harness acceptance and publication separate | `2026-09-13_board-attention-status.md` |
+| 23 | Discord connector: writes confined to the `shared-signals` root plus web fetch and search for the Discord Sage (MVP iteration 7) | coordinator; reviewer per Q12 | #1509 | done 2026-09-27 (Sage, lead decision 29): committed and pushed 1685deb4; live turns wrote `vault/Businesses/naming.md` with web_search and web_fetch in the turn record; the outside-root refusal rests on the offline suite, not a live turn | Sage writes a naming shortlist into the repository from #ideas with the write and web calls in the turn record; a write outside the root is refused | `2026-09-16_discord-write-and-web-tools.md` |
+| 24 | Discord connector: git verbs (status, commit, pull ff-only, push) for the Discord Sage on the `shared-signals` root, seat identity through the existing credential helper (MVP iteration 8) | coordinator; reviewer per Q12 | #1509 | done 2026-09-27 (Sage, lead decision 29): committed and pushed 1949ed8d; live-configured 2026-09-18, never used from Discord; records now go through row 25 and SetSpark cutover freezes `vault/`, so first real use is the check and a failure opens a new issue | Sage commits and pushes a decision file from #ideas; GitHub shows Sage as author with a Requested-by trailer; no token in any record | `2026-09-16_discord-git-tools.md` |
+| 25 | Discord connector: SetSpark record client for the Discord Sage, fixed verbs against setspark-api, connector-verified approvals (MVP iteration 9) | coordinator; reviewer per Q12 | #1509 | done 2026-09-26: committed and pushed 43d7574d, live check passed (DEC-010, request 2, approved by button at 21:38:58Z, lead decision 19); approver rule enforced by the service since shared-signals cc74d92 (lead decisions 24 and 28) | Sage creates one work item from #sage-admin and the API shows it with revision 1; a proposal is approved by the Approve button and the audit row carries Sage's key id and Jason's Discord id separately; rev-code-02 approves on #1509 | `2026-09-20_discord-setspark-client.md` |
+
+Start message for row 6, sent from the board to darkwing:
+"Read docs/plans/QUEUE.md, then the plan page section "Piece 5: darkwing on
+point", and execute it. Start with the first assignment for filbert. Report at
+Gate F or when blocked."
+
+## Parked (not in order, needs Jason to reopen)
+
+| Item | Why parked | Where |
+|------|------------|-------|
+| Registry increment 3 (headless identity-env leak) | #1500 closed on increment 2; Jason has not asked for 3 | CURRENT.md, "#1500 increment 2 (historical)" |
+| Auth/provider/harness registry review | Paused for owner alignment | `docs/plans/2026-09-03_auth-provider-harness-registry.md` |
+| CI runners, second real adapter, push automation | Deferred by owner 2026-09-03 | `docs/plans/ROADMAP.md`, "Explicitly deferred" |
+| 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.
+
+- 2026-09-13 — dewey: WebUI row advanced to Jason-owned Gate E after Filbert approval, exact committed-tree 208/208 and remote-verified ea00ec66 publication. No other row changed.
+- 2026-09-13 — rows 9 to 13 now point at the plan page `2026-09-13_queue-as-data.md` (pieces A to E, Gate G).
+
+- 2026-09-13 — dewey: Jason feedback blocks WebUI Gate E pending refinement; project/session chat navigation, two-way conversation and relative activity age required. Durable #1507 comment 26082; no implementation begun.
+
+- 2026-09-13 — coordinator: row 14 added (Discord connector pilot for Sage on Shared Signals), briefed, unassigned, waiting on Jason for decisions D1-D7. No other row changed.
+- 2026-09-13 — coordinator: row 14 moved to in progress (#1509) after Jason ruled Q1-Q27 through ms-grill-me. No other row changed.
+- 2026-09-13 — coordinator: row 14 moved to in review; package, suite, wrapper, DISCORD-USER.md draft and README written, offline and existing suites green. No other row changed.
+- 2026-09-13 — coordinator: row 14 stays in review; rev-code-02 round two findings (26121) fixed, round three requested. No other row changed.
+- 2026-09-13 — coordinator: row 14 stays in review; rev-code-02 round three finding (26123, stale reclaim race) fixed by failing closed plus `unlock`, round four requested. No other row changed.
+- 2026-09-13 — coordinator: row 14 stays in review; rev-code-02 round four finding (26132, unlock restore race) fixed with a `STOP` quiescence gate, round five requested. No other row changed.
+- 2026-09-13 — coordinator: row 14 stays in review; rev-code-02 round five findings (26150, boot identity and unlock wording) fixed, round six requested. No other row changed.
+- 2026-09-13 — coordinator: row 14 stays in review; rev-code-02 round six finding (26158, legacy record and unreadable record must refuse) fixed, round seven requested. No other row changed.
+- 2026-09-13 — coordinator: row 14 stays in review; rev-code-02 round seven finding (26165, malformed identity strings must refuse) fixed, round eight requested. No other row changed.
+- 2026-09-13 — coordinator: row 14 stays in review; rev-code-02 round eight finding (26168, noncanonical start digits must refuse) fixed, round nine requested. No other row changed.
+- 2026-09-13 — coordinator: row 14 moved to in pilot; approved 26170, committed 786e379c (no push), check passed, connector running. No other row changed.
+- 2026-09-13 — coordinator: row 14 done; Gate H passed, Jason ruled the replies read as Sage. Connector stays up; MVP improvements come as new rows. No other row changed.
+- 2026-09-13 — coordinator: row 15 added and done (Discord read receipt, Jason's request after the pilot); committed after suites green. No other row changed.
+- 2026-09-13 — coordinator: row 15 moved to in verification; committed 93d6b624, connector running the new code, waiting for Jason's live message to show the reaction. No other row changed.
+- 2026-09-13 — coordinator: row 15 done; live reaction confirmed by Jason and by the turn record. No other row changed.
+- 2026-09-13 — coordinator: rows 14–15 pushed on Jason's authorization (788515dc..dc5902aa to origin/refactor). No other row changed.
+
+- 2026-09-13: Darkwing recorded Jason's repository-native development priority as row 16, #1510. Darkwing coordinates the five internal agents; row 8 is held with no ~/.mosaic changes. CHAT-01C row 5 access text corrected to current push:true but Git HTTP 403. R1 26201 sent to default-socket Filbert for independent source review. Other owners' rows preserved.
+
+2026-09-13: #1510 internal Filbert APPROVE AS SOURCE for exact R1, manifest SHA256 23a27014ce6f04ce8187d8495b2efe62b814c3c7d041b027f99b1ec4d490709d. Both verdict and subsequent nine-file hash supplement agree with frozen candidate. Darkwing persisted attributed approval and all nine pins through verified Darkwing API identity, receipt 26204. Filbert independently passed six fake-engine tests in working and frozen copies; five no-effect startup checks and config 24/24 remain author evidence. No live interoperability, publication or user acceptance claimed. Reviewed source/charter left unchanged; prior owners retain their edits.
+- 2026-09-13 — coordinator: row 17 added, in verification (Discord service unit, iteration 2 on Jason's "proceed as suggested"). No other row changed.
+- 2026-09-13 — coordinator: row 17 done; the Sage connector runs under systemd, kill and brake checks passed live. No other row changed.
+- 2026-09-13 — coordinator: row 18 added, briefed; board-side work in darkwing's package, owner for Jason to rule. No other row changed.
+- 2026-09-13 — coordinator: rows 17–18 pushed (dc5902aa..90cb31f5 to origin/refactor) under the jarvis git identity on Jason's authorization. No other row changed.
+- 2026-09-13 — coordinator: row 18 assigned to darkwing by Jason ("darkwing should build the board row"). Coordinator no longer holds a Discord row in progress. No other row changed.
+- 2026-09-13 — coordinator: row 17 operator check passed (Jason: all tests successfully verified, 21:29 UTC). No other row changed.
+- 2026-09-13 — coordinator: rows 19 (binding reload) and 20 (per-user channels, Carmen) added on Jason's "proceed in order"; row 19 in progress. No other row changed.
+- 2026-09-13 — coordinator: rows 19–20 done (caaef941, live reload 00:03 UTC, Carmen enrolled); Carmen's first message pending as the operator check. No other row changed.
+- 2026-09-14 — coordinator: row 21 (read-only tools) briefed on Jason's "let's move to tools"; Carmen's test deferred by Jason; waiting on D1–D3 in the brief. No other row changed.
+- 2026-09-14 — coordinator: row 21 rulings in (brief section 7); building the extension, engine settle change and binding key.
+
+2026-09-14T00:09:08.161174+00:00 | Darkwing | #1503 board attention correction, row 21 | Jason approved idle for completed replies and waiting only for explicit human-input requests after reporting screenshot step-5 failure. New tests reproduced two failures before scanner correction. Phase entry recorded after implementation, not represented as an earlier entry. Working and frozen four-package suites now pass 144 tests each. Read-only actual Researcher scan returns alive:true, idle, waitingOnYou:false at lastActivity 2026-09-13T21:19:57.102Z. Explicit Input needed first-line convention, precedence and Seen lifecycle documented/tested; downstream fixture change acknowledged by Dewey. R1 26247 posted/read back as Darkwing and sent to internal Filbert, application verdict pending. No running-board restart, home launcher edit, agent restart or publication.
+
+2026-09-14T13:08:27.885681+00:00 | Darkwing | Queue collision correction | Git c4fc8e7d and index independently confirmed Discord read-only tools already owned row 21. Restored that committed row verbatim, moved Darkwing attention status to row 22, updated active CURRENT/charter/row1 cross-references. Historical row-21 attention mentions now refer to row 22; append-only logs and frozen review snapshots not rewritten. Unique ordered IDs 1-22 verified. No staging, commit, source/service changes or changes to coordinator-owned tools brief. Row21 newer work status remains coordinator-owned; no status inferred from this restoration. Incoming source label unverified has no established return address; acknowledgment routing unresolved.
+
+- 2026-09-14 — coordinator: row 21 rulings in (brief section 7), built and approved by rev-code-02 (26276); suite 48/48, 116 node tests; committed locally, live check next. No other row changed.
+- 2026-09-16 — coordinator: row 23 added and briefed (Discord writes and web, Jason: "A + web"); Discord profile Q16 line replaced by Jason's word and Sage restarted. No other row changed.
+- 2026-09-16 — coordinator: row 23 part 1 (write_file, edit_file confined to roots marked write: true; extension renamed tools.mjs) built and pinned for rev-code-02 round 1; Discord Sage's stale pi session archived and the service restarted so old Q16 refusals stop repeating.
+- 2026-09-16 — coordinator: row 24 (git verbs for the Discord Sage) briefed at Jason's word; row 23 writes enabled live in the private binding ahead of the review verdict, web tools built and under test.
+- 2026-09-16 — coordinator: row 23 part 2 (web_fetch, web_search via SearXNG, src/web.mjs) built; engine test busy/settled race fixed in the test; suite 52/52, node 128; round 2 pinned on #1509 comment 26358 for rev-code-02. Row 24 still waits on D5–D7.
+- 2026-09-16 — coordinator: row 23 part 3 live (SearXNG container mosaic-searxng on 127.0.0.1:8888, binding web key, restart); Jason's first web turn worked end to end but exposed a live defect: a second message during a turn was sent as a pi follow-up and lost the first answer. Engine now holds it until pi settles. Suite 52/52, node 129; round 3 pinned as #1509 comment 26361; service restarted with the fix. Row 24 rulings D5–D7 recorded.
+- 2026-09-17 — coordinator: shared-signals-05 briefed the new id registry and file lock protocol (jetrich/shared-signals 8f0d946); folded into the row 24 brief section 6 (validate before commit, reserve_id tool, per-write clone lock, explicit-path staging). No row changed.
+- 2026-09-18 — coordinator: row 23 pushed on Jason's word ("push as well"): refactor 90cb31f5..1685deb4, seven commits, identity jarvis. Row 24 built and under test: four git verbs plus reserve_id, package credential helper (the fleet helper cannot serve github.com), requester in the envelope, explicit-path staging; suite 58/58, node 143; review requested from rev-code-02 on #1509.
+- 2026-09-18 — coordinator: Jason's decision, relayed by the SetSpark record-system coordinator (thread 8543de4b, confirmed by Jason as acting on his authority): record authority moves from the Git vault to NocoDB (structured records) plus Outline (prose) on VM 1022, Pocket ID identity. This session is Sage's client: an HTTP tool module with fixed verbs, a fine-grained token from a 0600 file, output caps and one journal entry per call, built after that coordinator's plan is reviewed. Row 24 git verbs stay live until cutover; the vault becomes a read-only mirror after. Verb list and audit columns sent to the coordinator. No row change yet; a row 25 brief follows their plan.
+- 2026-09-20 — coordinator: SetSpark plan v3 reviewed (Phase 2 rules, Phase 3 verbs); all eight objections from the v2 review adopted; answered "phase 3 ok" with two non-blocking notes. Write target is setspark-api at api.setspark.io; row 25 brief waits on the accepted plan.
+- 2026-09-20 — coordinator: plan v3.1 committed on shared-signals main (361380c). Approval flow for row 25: open_approval_request on proposal post; the connector resolves the Approve button or an exact "approve" reply, checks the required-approver list, and submits request id, author id and message id. REF-045 conflict cleared by the REF-047 renumber (9287d49); row 24 live check unblocked. Phase 3 code waits on Jason's go.
+- 2026-09-20 — coordinator: Jason's go for phase 3 relayed (plan v3.2, shared-signals 55b2515). Row 25 part 2a built: setspark.mjs (config, key read per call, HTTP core, refusal rendering), approvals.mjs (ledger, reply and button resolution), connector posts the request message with the button, binds it, submits approvals, retries unknown ones on start. Tests 157 node, suite 62. Part 2b waits on stack/api/openapi.json. Contract needs (open without a message, bind, add_approval with request id, author id, message id) sent to the coordinator and adopted, with bound_message_id added on add_approval and a request_stale code; openapi.json being regenerated.
+- 2026-09-20 — coordinator: contract committed (shared-signals a5425a2). Row 25 part 2b built: eight model verbs (record_list, record_get, record_create, record_update, resolve_id, open_approval_request, get_approval_request, create_document; get_counters left out), the connector's api client (bind, add_approval with kind button or reply, get), the envelope's author and message ids read by the extension for the write keys. Coordinator's ruling on button evidence: the connector posts a confirmation line and submits its url and text as source_url and statement; a reply is its own evidence. Node 162, suite 63. Next: rev-code-02 review on #1509, then a local commit.
+- 2026-09-26 — sage (lead): Jason made Sage the project lead and Darkwing a collaborating seat. Rows 23–25 states reconciled with git: 1685deb4, 1949ed8d and 43d7574d are all on origin/refactor (the last two pushed today at Jason's word); each row's live check is still open. No other row changed.
+- 2026-09-26 — dewey: row 5 State corrected to git; b023841c (CHAT-01C) is on origin/refactor, so the held-publication and HTTP 403 text was stale. CHAT-02..08 held pending Jason via Sage. No other row changed.
+- 2026-09-26 — darkwing: row 6 State updated. #1511 and #1512 committed locally in af4203ca at Sage's ruling, on the serial acceptance evidence; the concurrency-only engine failures are filed against #1509. No other row changed.
+- 2026-09-26 — sage: row 5 State: CHAT-02 landed (backend a5beb6d9, Console c9e771cf) and passed its live check through the restarted WebUI. CHAT-03 waits for Jason. No other row changed.
+- 2026-09-26 — sage (lead): row 5 State: Jason approved CHAT-03. Dewey writes the brief; Filbert and Rocko review; the source author slot waits for Darkwing after A1/A2. No other row changed.
+- 2026-09-27 — sage (lead): rows 9 and 11 State: queue A1 committed (34a72af9) with the brief template; A2 in progress. No other row changed.
+- 2026-09-27 — sage (lead): row 5 State: CHAT-03 source work waits after brief r3 for a rescope against Gate E. Row 7: Sage runs the ledger each Monday. Weeks 09-13..19 and 09-20..26 came to 47.7 and 33.0 human messages per closed issue, against a target under 10. See `docs/plans/2026-09-27_goals-review.md`, lead decision 27. No other row changed.
+- 2026-09-27 — sage (lead): rows 1, 6, 23, 24 and 25 done (lead decision 29). #1503, #1509 and #1511 closed with evidence comments. Gate F recorded as not passed; Gate G carries its test. No other row changed.
+- 2026-09-27 — sage (lead): priority line: Jason ratified the goals review (north star and goal order). No row changed.
+- 2026-09-27 — sage (lead): row 5 State: CHAT-03 brief pinned at 1ef15ac0 after the scope check. Source author named after A2. No other row changed.
diff --git a/packages/queue/tests/helpers.mjs b/packages/queue/tests/helpers.mjs
index ecc61709206fa890dbf7d4e821c52102a2891b0d..14f127b336d4202ea63cafe4701e6dfc59f468c4 100644
--- a/packages/queue/tests/helpers.mjs
+++ b/packages/queue/tests/helpers.mjs
@@ -3,7 +3,7 @@
// the two discord helpers it imports, so the code-under-root check (8.3)
// passes there and nothing touches this checkout's .git.
import { spawnSync } from "node:child_process";
-import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
+import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statfsSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -74,6 +74,11 @@ export function queueMd(body = LEGACY) {
// `scripts`, it also commits queue-commit.sh, the guard and a one-test
// packages/queue/tests, so the commit procedure has an archive to run.
export function scratchRepo(t, { map = mapText(), commitMap = true, name = "repo", scripts = false } = {}) {
+ // The CLI refuses tmpfs (N5), and the child-process tests can't pass it a
+ // test layer, so a tmpfs temp directory would fail them all confusingly.
+ if (statfsSync(tmpdir()).type === 0x01021994) {
+ throw new Error(`${tmpdir()} is tmpfs, which the queue refuses; set TMPDIR to a directory on ext4, xfs or btrfs`);
+ }
const base = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-queue-test-")));
if (t) t.after(() => rmSync(base, { recursive: true, force: true }));
const home = join(base, "home");
diff --git a/packages/queue/tests/lock.test.mjs b/packages/queue/tests/lock.test.mjs
index f1b109d90151c6ef8c054a2bd708782ab711ef8e..c2bf1c48290219bf04a2ea0d689cd380246c54d1 100644
--- a/packages/queue/tests/lock.test.mjs
+++ b/packages/queue/tests/lock.test.mjs
@@ -109,6 +109,36 @@ test("an error after the link releases the lock: unreadable gate, failing temp s
assert.deepEqual(readdirSync(d), []);
});
+// P1: a release that throws on either gate path becomes a message naming the
+// lock left behind, and the gate-present refusal reports what release found.
+test("a release that fails on a gate path is reported, never a stack trace (P1)", (t) => {
+ const d = dir(t);
+ const lock = join(d, LOCK_NAME);
+ const eacces = () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); };
+ const noUnlink = { ...realIo, unlink: (p) => (p === lock ? eacces() : realIo.unlink(p)) };
+ // The gate is present.
+ writeFileSync(join(d, GATE_NAME), record({ verb: "unlock", op: null }));
+ refused(() => acquire({ gitDir: d, io: noUnlink, verb: "move" }),
+ /unlock gate .* is present \(live: .*--check-gate`\nwarning: cannot release the queue lock \(EACCES\); .*mosaic-queue\.lock may be left in place$/);
+ assert.equal(existsSync(lock), true);
+ rmSync(lock);
+ // The gate can't be read.
+ const both = { ...noUnlink, readFile: (p) => (p.endsWith(GATE_NAME) ? eacces() : realIo.readFile(p)) };
+ refused(() => acquire({ gitDir: d, io: both, verb: "move" }),
+ /cannot check the unlock gate .*EACCES; cannot release the queue lock \(EACCES\); .*mosaic-queue\.lock may be left in place$/, 1);
+ assert.equal(existsSync(lock), true);
+ rmSync(lock);
+ // The gate is present and the lock was swapped: release's own message is kept.
+ const swap = (name) => { if (name === "lock-linked") { writeFileSync(`${lock}.copy`, readFileSync(lock)); renameSync(`${lock}.copy`, lock); } };
+ refused(() => acquire({ gitDir: d, io: realIo, verb: "move", hook: swap }),
+ /is present \(live: .*\nwarning: lock .*mosaic-queue\.lock is not the one this process took; left in place$/);
+ assert.equal(existsSync(lock), true);
+ rmSync(lock);
+ // Nothing wrong: the refusal carries no warning.
+ refused(() => acquire({ gitDir: d, io: realIo, verb: "move" }), /--check-gate`$/);
+ assert.deepEqual(readdirSync(d), [GATE_NAME]);
+});
+
test("a paused holder: another writer waits 10 s, then refuses naming it live", async (t) => {
const d = dir(t);
const child = spawn(process.execPath, [join(HERE, "fixtures", "lock-child.mjs"), d, "hold"], { stdio: ["ignore", "pipe", "ignore"] });
@@ -120,7 +150,7 @@ test("a paused holder: another writer waits 10 s, then refuses naming it live",
child.kill("SIGKILL");
await new Promise((r) => child.on("exit", r));
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /owner is dead: pid .*; run `scripts\/mosaic queue unlock` once nothing is running/);
- assert.match(unlock({ gitDir: d, io: realIo }), /^removed queue lock \(dead: pid/);
+ assert.match(unlock({ gitDir: d, io: realIo }).result, /^removed queue lock \(dead: pid/);
release(acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), realIo);
});
@@ -136,7 +166,8 @@ test("two concurrent unlockers: the second refuses on the gate", async (t) => {
},
});
assert.match(inner.message, /unlock gate .* is held \(live: pid \d+, unlock since/);
- assert.match(outer, /^removed queue lock \(dead/);
+ assert.match(outer.result, /^removed queue lock \(dead/);
+ assert.equal(outer.warning, null);
assert.equal(existsSync(join(d, GATE_NAME)), false);
});
@@ -166,7 +197,7 @@ test("a writer publishing during an unlock, gate first: the writer releases and
},
});
assert.match(writerErr.message, /unlock gate .* is present \(live: .*unlock/);
- assert.equal(out, "no queue lock present; nothing removed");
+ assert.deepEqual(out, { result: "no queue lock present; nothing removed", warning: null });
assert.deepEqual(readdirSync(d), []);
});
@@ -176,7 +207,8 @@ test("a gate swapped while held is left in place and reported, on success and on
// A copy renamed over the gate: same bytes, a new inode.
const swap = () => { writeFileSync(`${gate}.copy`, readFileSync(gate)); renameSync(`${gate}.copy`, gate); };
const out = unlock({ gitDir: d, io: realIo, hook: (name) => { if (name === "gate-held") swap(); } });
- assert.match(out, /^no queue lock present; nothing removed\nwarning: lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
+ assert.equal(out.result, "no queue lock present; nothing removed");
+ assert.match(out.warning, /^lock .*mosaic-queue\.unlock is not the one this process took; left in place$/);
assert.equal(existsSync(gate), true);
rmSync(gate);
writeFileSync(join(d, LOCK_NAME), record({}));
@@ -194,7 +226,7 @@ test("a reused pid within one boot is mismatch; unlock removes the lock and neve
writeFileSync(join(d, LOCK_NAME), record({ pid: s.pid, start: wrong }));
assert.equal(classify(readFileSync(join(d, LOCK_NAME)), realProc).state, "mismatch");
refused(() => acquire({ gitDir: d, io: realIo, verb: "move", waitMs: 0 }), /mismatch: .*was reused/);
- assert.match(unlock({ gitDir: d, io: realIo }), /removed queue lock \(mismatch/);
+ assert.match(unlock({ gitDir: d, io: realIo }).result, /removed queue lock \(mismatch/);
assert.equal(s.exitCode, null);
assert.equal(processStart(s.pid), start, "the process still runs, unsignalled");
});
@@ -258,7 +290,7 @@ test("a delayed release by a dead owner, after unlock and a new owner: the inode
const a = acquire({ gitDir: d, io: realIo, op: "owner-a-op", verb: "move" });
// Unlock judges A dead (as it would after a crash); B then takes the lock.
const judge = { ...realProc, pidAlive: () => false };
- assert.match(unlock({ gitDir: d, io: realIo, proc: judge }), /removed queue lock \(dead/);
+ assert.match(unlock({ gitDir: d, io: realIo, proc: judge }).result, /removed queue lock \(dead/);
const b = acquire({ gitDir: d, io: realIo, op: "owner-b-op", verb: "move" });
assert.match(release(a, realIo), /is not the one this process took; left in place/);
assert.match(readFileSync(join(d, LOCK_NAME), "utf8"), /owner-b-op/);
@@ -279,7 +311,7 @@ test("release checks the inode too: a byte-identical lock file with a new inode
test("unlock refuses a live, unknown or invalid lock, and does nothing without one", async (t) => {
const d = dir(t);
- assert.equal(unlock({ gitDir: d, io: realIo }), "no queue lock present; nothing removed");
+ assert.deepEqual(unlock({ gitDir: d, io: realIo }), { result: "no queue lock present; nothing removed", warning: null });
writeFileSync(join(d, LOCK_NAME), "");
refused(() => unlock({ gitDir: d, io: realIo }), /owner is invalid/);
writeFileSync(join(d, LOCK_NAME), record());
diff --git a/packages/queue/tests/migration.test.mjs b/packages/queue/tests/migration.test.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..f00986ee8e0cfea1d1374d2468078b4ee870c97e
--- /dev/null
+++ b/packages/queue/tests/migration.test.mjs
@@ -0,0 +1,77 @@
+// The real migration (A2): the reviewed map renders the golden genesis
+// table, and the marked QUEUE.md keeps every row between its markers for
+// genesis's legacyView. map-check.mjs, the pre-genesis drift check, is
+// tested on the frozen fixture here; no test reads the live QUEUE.md.
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+import { genesisRows, parseMigrationMap, render, splitView, validateRows } from "../src/queue.mjs";
+import { check, tableLines } from "../../../agents/darkwing/work/queue-a2/map-check.mjs";
+
+const HERE = new URL(".", import.meta.url).pathname;
+const MAP = readFileSync(`${HERE}../../../agents/darkwing/work/queue-migration-map.md`, "utf8");
+const MARKED = readFileSync(`${HERE}fixtures/queue-marked.md`, "utf8");
+const GOLDEN = readFileSync(`${HERE}fixtures/genesis-render.md`, "utf8");
+
+test("the migration map validates and renders the golden genesis table", () => {
+ const map = parseMigrationMap(MAP);
+ assert.deepEqual(map.rows.map((r) => r.id), Array.from({ length: 30 }, (_, i) => i + 1));
+ assert.equal(map.highWater, 30);
+ assert.deepEqual(map.retired, []);
+ const blobs = new Map(map.rows.filter((r) => r.brief).map((r) => [r.id, "a".repeat(40)]));
+ const rows = genesisRows(map, blobs, "genesis-2026-09-27", "2026-09-27T00:00:00.000Z", "sage");
+ validateRows(rows);
+ assert.equal(render(rows, 0), GOLDEN);
+ assert.deepEqual(rows.filter((r) => r.claim).map((r) => [r.id, r.claim.seat]), [[5, "dewey"], [7, "sage"], [9, "darkwing"], [11, "darkwing"], [16, "darkwing"]]);
+ assert.deepEqual(rows.filter((r) => r.state === "parked").map((r) => r.id), [8, 26, 27, 28, 29, 30]);
+ assert.deepEqual(rows.filter((r) => r.required).map((r) => r.id), [9, 10, 11, 12, 13]);
+});
+
+test("the marked QUEUE.md holds every row and parked item between its markers", () => {
+ const parts = splitView(MARKED);
+ assert.notEqual(parts, null);
+ const inside = tableLines(parts.body);
+ assert.deepEqual([...inside.keys()], Array.from({ length: 30 }, (_, i) => i + 1));
+ assert.deepEqual(tableLines(MARKED), inside);
+ assert.equal(tableLines(parts.head).size, 0);
+ assert.match(parts.head, /^## Pieces \(in order\)\n\n<!-- mosaic-queue:begin -->\n$/m);
+ assert.match(parts.tail, /^<!-- mosaic-queue:end -->\n\n## Log of table changes\n/);
+ assert.deepEqual(check(MARKED, MAP, MARKED), []);
+});
+
+test("map-check reports each kind of drift", () => {
+ const line = (id) => tableLines(MARKED).get(id);
+ const edit = (id, to) => MARKED.replace(line(id), to);
+ const cellsOf = (id) => line(id).slice(2, -2).split(" | ");
+ const row = (c) => `| ${c.join(" | ")} |`;
+
+ const c9 = cellsOf(9);
+ const piece = edit(9, row([c9[0], "Queue as data, renamed", ...c9.slice(2)]));
+ assert.deepEqual(check(piece, MAP, MARKED), ["row 9: changed since the map's QUEUE.md blob", "row 9: piece differs from the map"]);
+
+ const c10 = cellsOf(10);
+ const owner = edit(10, row([c10[0], c10[1], "sage", ...c10.slice(3)]));
+ assert.deepEqual(check(owner, MAP, MARKED), ["row 10: changed since the map's QUEUE.md blob", "row 10: owner sage in QUEUE.md, coordinator in the map"]);
+
+ const c16 = cellsOf(16);
+ const issue = edit(16, row([...c16.slice(0, 3), "#1510, #1520", ...c16.slice(4)]));
+ assert.deepEqual(check(issue, MAP, MARKED), ["row 16: changed since the map's QUEUE.md blob", "row 16: issues 1510,1520 in QUEUE.md, 1510 in the map"]);
+
+ const state = edit(5, line(5).replace("Gate E blocked", "Gate E rescoped"));
+ assert.deepEqual(check(state, MAP, MARKED), ["row 5: changed since the map's QUEUE.md blob"]);
+
+ const parked = edit(27, line(27).replace("Auth/provider/harness registry review", "Registry review"));
+ assert.deepEqual(check(parked, MAP, MARKED), ["row 27: changed since the map's QUEUE.md blob", 'row 27: parked item "Registry review" is not the map\'s piece']);
+
+ const gone = MARKED.replace(`${line(12)}\n`, "");
+ assert.deepEqual(check(gone, MAP, MARKED), ["row 12: removed since the map's QUEUE.md blob", "row 12: in the map, not in QUEUE.md"]);
+
+ // A new row 26 moves the parked items to 27..31, so the map's ids no
+ // longer line up and every shifted row reports.
+ const added = MARKED.replace(`${line(25)}\n`, `${line(25)}\n| 26 | New piece | darkwing | — | queued | g | b |\n`);
+ const found = check(added, MAP, MARKED);
+ assert.ok(found.includes("row 26: changed since the map's QUEUE.md blob"));
+ assert.ok(found.includes("row 26: piece differs from the map"));
+ assert.ok(found.includes("row 31: in QUEUE.md, not in the map"));
+ assert.equal(found.filter((d) => / parked item /.test(d)).length, 4);
+});
diff --git a/packages/queue/tests/store.test.mjs b/packages/queue/tests/store.test.mjs
index 70425a7329f73454efbfd9ee0e7c29a945c02ac8..c98bb82089646754f117a1741fa1ecd5a5a3b6f4 100644
--- a/packages/queue/tests/store.test.mjs
+++ b/packages/queue/tests/store.test.mjs
@@ -128,6 +128,28 @@ test("canonical checks: worktree, second clone, detached HEAD, wrong branch, GIT
ok(cli(repo, ["list"], { cwd: join(link, "docs") }), /^1\tdone/);
});
+test("--by that differs from MOSAIC_AGENT_NAME warns on stderr and logs nothing more (N12)", (t) => {
+ const warn = (by, env) => `warning: --by ${JSON.stringify(by)} differs from MOSAIC_AGENT_NAME=${JSON.stringify(env)}`;
+ // Genesis goes through the same check.
+ const fresh = scratchRepo(t);
+ const g = ok(cli(fresh, [...genesisArgs(fresh), "--by", "sage"], { by: "darkwing" }), /^ok genesis-2026-09-26 rev 0 genesis/);
+ assert.equal(g.err, `${warn("sage", "darkwing")}\n`);
+ const repo = ready(t);
+ // --by wins and the log records only the actor.
+ const r = ok(cli(repo, ["note", "9", "hello", "--op", "note-n12-0001", "--by", "darkwing"], { by: "dewey" }), /^ok note-n12-0001 rev 1/);
+ assert.equal(r.err.split("\n")[0], warn("darkwing", "dewey"));
+ const e = doc(repo).log.at(-1);
+ assert.deepEqual([e.by, Object.keys(e).join()], ["darkwing", "rev,op,verb,args,by,at,semantics,result,viewSha"]);
+ // A refusal carries it too.
+ const n = no(cli(repo, ["note", "9", "again", "--op", "note-n12-0002", "--by", "rocko"], { by: "darkwing" }), 2, /only the owner/);
+ assert.equal(n.err.trimEnd().split("\n").at(-1), warn("rocko", "darkwing"));
+ // Same name, or only one of the two: no warning.
+ assert.equal(ok(cli(repo, ["note", "9", "three", "--op", "note-n12-0003", "--by", "darkwing"], { by: "darkwing" })).err, "");
+ assert.equal(ok(cli(repo, ["note", "9", "four", "--op", "note-n12-0004"], { by: "darkwing" })).err, "");
+ assert.equal(ok(cli(repo, ["note", "9", "five", "--op", "note-n12-0005", "--by", "darkwing"])).err, "");
+ assert.equal(ok(cli(repo, ["note", "9", "six", "--op", "note-n12-0006", "--by", "darkwing"], { by: "" })).err, "");
+});
+
test("op ids: missing, too long, reserved; a retry answers; another payload refuses", (t) => {
const repo = ready(t);
no(cli(repo, ["note", "9", "hello"], { by: "darkwing" }), 4, /--op ID is required/);
diff --git a/packages/queue/tests/write.test.mjs b/packages/queue/tests/write.test.mjs
index 35e9829a56519e3ac938ee42b31b8b1c10788235..1744cc66d6b1cfe2e5bec7c97488416c780dbb87 100644
--- a/packages/queue/tests/write.test.mjs
+++ b/packages/queue/tests/write.test.mjs
@@ -4,8 +4,10 @@
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
+import { hostname } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
+import { processStart } from "../../discord/src/journal.mjs";
import { cli, genesisCommitted, load, scratchRepo } from "./helpers.mjs";
const HERE = new URL(".", import.meta.url).pathname;
@@ -164,6 +166,22 @@ test("a lock swapped while held is left in place and reported, on a receipt and
unlinkSync(lock);
});
+// P1 in withLock: a release that throws after the op becomes a warning on
+// the receipt or on the refusal, and names the lock left behind.
+test("a lock that cannot be released after an op is reported, on a receipt and on a refusal", async (t) => {
+ const { repo, m } = await ready(t);
+ const lock = join(repo.gitDir, "mosaic-queue.lock");
+ const eacces = () => { throw Object.assign(new Error("denied"), { code: "EACCES" }); };
+ const io = { ...m.io.realIo, unlink: (p) => (p === lock ? eacces() : m.io.realIo.unlink(p)) };
+ const done = m.store.mutate(o(repo, { io }), note(9, "x", "note-9-000001"));
+ assert.match(done.out[0], /^ok note-9-000001 rev 1/);
+ assert.match(done.err.join("\n"), /warning: cannot release the queue lock \(EACCES\); .*mosaic-queue\.lock may be left in place$/);
+ unlinkSync(lock);
+ throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "y", "note-9-000002", "rocko")), 2,
+ /may note row 9[^]*\nwarning: cannot release the queue lock \(EACCES\); .*mosaic-queue\.lock may be left in place$/);
+ unlinkSync(lock);
+});
+
test("unlock prints a swapped gate's warning on stderr, the result on stdout", async (t) => {
const { repo, m } = await ready(t);
const gate = join(repo.gitDir, "mosaic-queue.unlock");
@@ -369,5 +387,40 @@ test("an accept-history in progress: an unlocked reader waits on the lock and ne
test("the platform check refuses other filesystems", async (t) => {
const { repo, m } = await ready(t);
const io = { ...m.io.realIo, statfsType: () => 0x6969 };
- throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "one", "note-9-000001")), 2, /unsupported filesystem .*type 0x6969/);
+ throwsCode(() => m.store.mutate(o(repo, { io }), note(9, "one", "note-9-000001")), 2, /unsupported filesystem .*type 0x6969\); ext4, xfs or btrfs only/);
+});
+
+test("tmpfs passes only a test layer that allows it (N5)", async (t) => {
+ const { repo, m } = await ready(t);
+ assert.equal(m.io.TMPFS, 0x01021994);
+ assert.equal("allowTmpfs" in m.io.realIo, false);
+ assert.equal(Object.isFrozen(m.io.realIo), true);
+ const tmpfs = { ...m.io.realIo, statfsType: () => m.io.TMPFS };
+ throwsCode(() => m.store.mutate(o(repo, { io: tmpfs }), note(9, "one", "note-9-000001")), 2, /unsupported filesystem .*type 0x1021994\); ext4, xfs or btrfs only/);
+ throwsCode(() => m.store.unlock(o(repo, { io: tmpfs })), 2, /type 0x1021994/);
+ throwsCode(() => m.store.mutate(o(repo, { io: { ...tmpfs, allowTmpfs: "yes" } }), note(9, "one", "note-9-000001")), 2, /type 0x1021994/);
+ const r = m.store.mutate(o(repo, { io: { ...tmpfs, allowTmpfs: true } }), note(9, "one", "note-9-000001"));
+ assert.match(r.out[0], /^ok note-9-000001 rev 1/);
+ // allowTmpfs lets tmpfs through, nothing else.
+ throwsCode(() => m.store.mutate(o(repo, { io: { ...m.io.realIo, statfsType: () => 0x6969, allowTmpfs: true } }), note(9, "two", "note-9-000002")), 2, /type 0x6969/);
+});
+
+// P3: unlock returns the result and the warning apart, so a lock record
+// written by hand over several lines stays on stdout.
+test("unlock keeps a multi-line lock record on stdout (P3)", async (t) => {
+ const { repo, m } = await ready(t);
+ const lock = join(repo.gitDir, "mosaic-queue.lock");
+ const rec = (pid) => ({
+ pid, start: processStart(process.pid), boot: "00000000-0000-4000-8000-000000000000", host: hostname(), op: "hand-op-0001", verb: "move", at: "2026-09-26T00:00:00.000Z",
+ });
+ writeFileSync(lock, JSON.stringify(rec(process.pid), null, 2) + "\n");
+ const r = m.store.unlock(o(repo));
+ assert.equal(r.out.length, 1);
+ assert.match(r.out[0], /^removed queue lock \(mismatch: .*recorded in a previous boot\): \{\n {2}"pid": \d+,\n[^]*"at": "2026-09-26T00:00:00\.000Z"\n\}$/);
+ assert.deepEqual(r.err, []);
+ writeFileSync(lock, JSON.stringify(rec(process.pid), null, 2) + "\n");
+ const c = cli(repo, ["unlock"]);
+ assert.equal(c.code, 0, c.err);
+ assert.equal(c.err, "");
+ assert.match(c.out, /"verb": "move",\n {2}"at"/);
});
diff --git a/scripts/mosaic b/scripts/mosaic
index 0a50fdb74d7a9b7aa2e82a36ee5b067f654b058d..3fb631d6354d3e1ee1f10b1b0d4e3efcde2eb163 100755
--- a/scripts/mosaic
+++ b/scripts/mosaic
@@ -1,8 +1,13 @@
#!/usr/bin/env bash
# `mosaic launch <seat>` and `mosaic seat task <seat> <text>`: seat launch
# with registration for the control board. See packages/seat/README.md.
+# `mosaic queue <verb>`: the work queue. See packages/queue/README.md.
# Not the npm-global `mosaic` CLI from the estate tooling; this one is
# repository-local and only reachable as scripts/mosaic.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+if [ "${1:-}" = queue ]; then
+ shift
+ exec node "$REPO/packages/queue/src/cli.mjs" "$@"
+fi
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
diff --git a/scripts/test-queue.sh b/scripts/test-queue.sh
index eb11e98ce21f6683a8d2698cb65dc85eeca099ea..dc681d30e5ebfbae75f024d7523105418c79743c 100755
--- a/scripts/test-queue.sh
+++ b/scripts/test-queue.sh
@@ -7,8 +7,10 @@
# NO_COLOR=1 scripts/test-queue.sh plain output
#
# Once HEAD holds docs/plans/queue.json (the genesis commit), the suite also
-# runs `verify` on the live queue, which takes the queue lock for a moment
-# and writes nothing.
+# runs `verify` and `render --check` on the live queue through
+# `scripts/mosaic queue`. Each takes the queue lock for a moment and writes
+# nothing. Before genesis it only checks that `scripts/mosaic queue help`
+# answers, which reads no queue file and takes no lock.
set -uo pipefail
cd "$(dirname "$0")/.."
@@ -29,16 +31,16 @@ echo "toolchain: node $(node --version), $(git --version)"
echo
# --- syntax ---
-for f in packages/queue/src/*.mjs packages/queue/tests/*.mjs packages/queue/tests/fixtures/*.mjs scripts/queue-commit.sh scripts/git-hooks/pre-commit; do
+for f in packages/queue/src/*.mjs packages/queue/tests/*.mjs packages/queue/tests/fixtures/*.mjs packages/queue/tests/fixtures/*.sh scripts/queue-commit.sh scripts/git-hooks/pre-commit scripts/mosaic; do
case "$f" in
- *.sh) bash -n "$f" >/dev/null 2>&1 ;;
+ *.sh|scripts/mosaic) bash -n "$f" >/dev/null 2>&1 ;;
*/pre-commit) sh -n "$f" >/dev/null 2>&1 ;;
*) node --check "$f" >/dev/null 2>&1 ;;
esac
check "syntax: $f" $?
done
-[ -x scripts/queue-commit.sh ] && [ -x scripts/git-hooks/pre-commit ]
-check "queue-commit.sh and the guard are executable" $?
+[ -x scripts/queue-commit.sh ] && [ -x scripts/git-hooks/pre-commit ] && [ -x scripts/mosaic ]
+check "queue-commit.sh, the guard and scripts/mosaic are executable" $?
node -e 'const p=require("./packages/queue/package.json"); process.exit(p.dependencies||p.devDependencies?1:0)' >/dev/null 2>&1
check "packages/queue declares no dependencies" $?
@@ -49,12 +51,16 @@ grep -E '^ℹ (tests|pass|fail) ' "$SANDBOX/tests.log"
if [ "$rc" != 0 ]; then grep -E '^✖|Error' "$SANDBOX/tests.log" | head -40; fi
check "node --test packages/queue/tests/" "$rc"
-# --- the live queue, once genesis is committed ---
+# --- the dispatch, then the live queue once genesis is committed ---
+scripts/mosaic queue help > "$SANDBOX/help.out" 2>&1 && grep -q '^usage: queue list' "$SANDBOX/help.out"
+check "scripts/mosaic queue help" $?
if git cat-file -e HEAD:docs/plans/queue.json 2>/dev/null; then
- node packages/queue/src/cli.mjs verify
- check "queue verify (live queue)" $?
+ scripts/mosaic queue verify
+ check "scripts/mosaic queue verify (live queue)" $?
+ scripts/mosaic queue render --check
+ check "scripts/mosaic queue render --check (live queue)" $?
else
- echo "skip queue verify: HEAD has no docs/plans/queue.json (before the genesis commit)"
+ echo "skip queue verify and render --check: HEAD has no docs/plans/queue.json (before the genesis commit)"
fi
echo