feat(queue): queue as data A1, journal, lock, CLI and verify (#1508)

packages/queue, scripts/queue-commit.sh, scripts/git-hooks and
scripts/test-queue.sh, plus docs/plans/BRIEF-TEMPLATE.md. There is no
queue.json yet, so verify skips until the genesis commit after A2.

Darkwing built it, and Filbert reviewed R0 (6933b885, changes requested)
and r1 (e464be6c, approved). The 20 files match manifest 85a8a453. The
nine suites passed on an index export, including the new queue suite.
test-queue.sh joins the suite list in AGENTS.md. Lead decisions 20, 23
and 26.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 19:07:48 -05:00
co-authored by Claude Opus 5.5
parent 91df7d6b54
commit 34a72af912
32 changed files with 11592 additions and 1 deletions
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
// Usage: queue <verb> [args] (A2 adds `scripts/mosaic queue` in front).
//
// Reads: list | show ID | next [SEAT]
// Changes: add --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT]
// [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required]
// move ID STATE [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT]
// release ID | assign ID SEAT | note ID TEXT | set ID FIELD VALUE [--reason TEXT]
// genesis --root PATH --branch NAME --map PATH
// accept-history --reason TEXT --yes
// Each needs --op ID and an actor (--by NAME or $MOSAIC_AGENT_NAME).
// No log: render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)
// sync [--op ID] | snapshot --out DIR | unlock [--check-gate]
//
// Exit codes: 0 ok; 1 operation failed; 2 invalid data or refused;
// 3 uncertain (visible or durable, not acknowledged); 4 usage.
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { QueueError } from "./errors.mjs";
import { SET_FIELDS } from "./queue.mjs";
import { list, mutate, next, renderView, show, snapshot, sync, unlock, verify, verifySnapshot } from "./store.mjs";
const USAGE = [
"usage: queue list | show ID | next [SEAT]",
" queue add --op ID --piece TEXT --gate TEXT --brief PATH#ANCHOR [--issue N]... [--note TEXT] [--owner SEAT] [--gate-owner SEAT] [--after ID[:settled]]... [--reviewer SEAT]... [--required]",
" queue move ID STATE --op ID [--reason TEXT] [--candidate COMMIT|MANIFEST] [--issue N] [--evidence TEXT]",
" queue release ID --op ID | assign ID SEAT --op ID | note ID TEXT --op ID",
` queue set ID FIELD VALUE --op ID [--reason TEXT] (fields: ${SET_FIELDS.join(", ")})`,
" queue genesis --op ID --root PATH --branch NAME --map PATH",
" queue accept-history --op ID --reason TEXT --yes",
" queue render [--check] | verify [--current] | verify --snapshot DIR (--base-file F | --base-absent)",
" queue sync [--op ID] | snapshot --out DIR | unlock [--check-gate]",
" every change takes --by NAME, else $MOSAIC_AGENT_NAME",
].join("\n");
const VALUE_FLAGS = new Set([
"--op", "--by", "--piece", "--gate", "--brief", "--issue", "--note", "--owner", "--gate-owner", "--after", "--reviewer",
"--reason", "--candidate", "--evidence", "--root", "--branch", "--map", "--snapshot", "--base-file", "--out",
]);
const REPEATED = new Set(["--issue", "--after", "--reviewer"]);
const BOOL_FLAGS = new Set(["--required", "--yes", "--check", "--current", "--base-absent", "--check-gate"]);
function usage(message) {
return new QueueError(message, 4);
}
export function parseArgs(argv) {
const pos = [];
const flags = new Map();
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--") { pos.push(...argv.slice(i + 1)); break; }
if (VALUE_FLAGS.has(a)) {
if (i + 1 >= argv.length) throw usage(`missing value for ${a}`);
const v = argv[++i];
if (REPEATED.has(a)) flags.set(a, [...(flags.get(a) ?? []), v]);
else if (flags.has(a)) throw usage(`${a} given twice`);
else flags.set(a, v);
} else if (BOOL_FLAGS.has(a)) {
flags.set(a, true);
} else if (a.startsWith("--")) {
throw usage(`unknown option ${a}`);
} else {
pos.push(a);
}
}
return { pos, flags };
}
function allow(flags, names) {
for (const k of flags.keys()) if (!names.includes(k)) throw usage(`${k} does not apply here`);
}
function positional(pos, n, what) {
if (pos.length !== n) throw usage(`expected ${what}`);
}
function intArg(v, what) {
if (!/^[1-9][0-9]{0,6}$/.test(v)) throw usage(`${what} must be a positive integer: ${JSON.stringify(v)}`);
return Number(v);
}
function afterArg(v) {
const m = /^([1-9][0-9]{0,6})(?::(done|settled))?$/.exec(v);
if (!m) throw usage(`--after takes ID or ID:settled: ${JSON.stringify(v)}`);
return { id: Number(m[1]), when: m[2] ?? "done" };
}
function listArg(v) {
return v === "" || v === "none" ? [] : v.split(",");
}
function setValue(field, v) {
switch (field) {
case "after": return listArg(v).map(afterArg);
case "reviewers": return listArg(v);
case "issues": case "closes": return listArg(v).map((n) => intArg(n.replace(/^#/, ""), field));
case "required":
if (v !== "true" && v !== "false") throw usage("required takes true or false");
return v === "true";
default: return v;
}
}
const CHANGE = ["--op", "--by"];
export function run(argv, opts = {}) {
const [verb, ...rest] = argv;
if (verb === undefined || verb === "--help" || verb === "-h" || verb === "help") return { out: [USAGE], err: [], code: verb === undefined ? 4 : 0 };
const { pos, flags } = parseArgs(rest);
const f = (k) => flags.get(k) ?? null;
const change = (v, args, extra = {}) => mutate(opts, { verb: v, op: f("--op"), args, by: f("--by"), ...extra });
switch (verb) {
case "list":
allow(flags, []); positional(pos, 0, "no arguments");
return list(opts);
case "show":
allow(flags, []); positional(pos, 1, "show ID");
return show(opts, intArg(pos[0], "ID"));
case "next":
allow(flags, []);
if (pos.length > 1) throw usage("expected next [SEAT]");
return next(opts, pos[0] ?? null);
case "add": {
allow(flags, [...CHANGE, "--piece", "--gate", "--brief", "--issue", "--note", "--owner", "--gate-owner", "--after", "--reviewer", "--required"]);
positional(pos, 0, "only options");
for (const k of ["--piece", "--gate", "--brief"]) if (f(k) === null) throw usage(`add needs ${k}`);
return change("add", {
piece: f("--piece"), gate: f("--gate"), brief: f("--brief"),
issues: (flags.get("--issue") ?? []).map((n) => intArg(n.replace(/^#/, ""), "--issue")),
note: f("--note"), owner: f("--owner"), gateOwner: f("--gate-owner"),
after: flags.has("--after") ? flags.get("--after").map(afterArg) : null,
reviewers: flags.get("--reviewer") ?? null, required: flags.has("--required") ? true : null,
});
}
case "move":
allow(flags, [...CHANGE, "--reason", "--candidate", "--issue", "--evidence"]); positional(pos, 2, "move ID STATE");
if ((flags.get("--issue") ?? []).length > 1) throw usage("move takes one --issue");
return change("move", {
id: intArg(pos[0], "ID"), to: pos[1], reason: f("--reason"), candidate: f("--candidate"), evidence: f("--evidence"),
issue: flags.has("--issue") ? intArg(flags.get("--issue")[0].replace(/^#/, ""), "--issue") : null,
});
case "release":
allow(flags, CHANGE); positional(pos, 1, "release ID");
return change("release", { id: intArg(pos[0], "ID") });
case "assign":
allow(flags, CHANGE); positional(pos, 2, "assign ID SEAT");
return change("assign", { id: intArg(pos[0], "ID"), seat: pos[1] });
case "note":
allow(flags, CHANGE); positional(pos, 2, "note ID TEXT");
return change("note", { id: intArg(pos[0], "ID"), text: pos[1] });
case "set":
allow(flags, [...CHANGE, "--reason"]); positional(pos, 3, "set ID FIELD VALUE");
if (!SET_FIELDS.includes(pos[1])) throw usage(`set fields: ${SET_FIELDS.join(", ")}`);
return change("set", { id: intArg(pos[0], "ID"), field: pos[1], value: setValue(pos[1], pos[2]), reason: f("--reason") });
case "genesis":
allow(flags, [...CHANGE, "--root", "--branch", "--map"]); positional(pos, 0, "only options");
for (const k of ["--root", "--branch", "--map"]) if (f(k) === null) throw usage(`genesis needs ${k}`);
return change("genesis", { root: f("--root"), branch: f("--branch"), map: f("--map") });
case "accept-history":
allow(flags, [...CHANGE, "--reason", "--yes"]); positional(pos, 0, "only options");
if (f("--reason") === null) throw usage("accept-history needs --reason");
return change("accept-history", { reason: f("--reason") }, { yes: flags.has("--yes") });
case "render":
allow(flags, ["--check"]); positional(pos, 0, "no arguments");
return renderView(opts, { check: flags.has("--check") });
case "verify":
if (flags.has("--snapshot")) {
allow(flags, ["--snapshot", "--base-file", "--base-absent"]); positional(pos, 0, "only options");
return verifySnapshot(f("--snapshot"), { baseFile: f("--base-file"), baseAbsent: flags.has("--base-absent") });
}
allow(flags, ["--current"]); positional(pos, 0, "no arguments");
return verify(opts, { current: flags.has("--current") });
case "sync":
allow(flags, ["--op"]); positional(pos, 0, "no arguments");
return sync(opts, f("--op"));
case "snapshot":
allow(flags, ["--out"]); positional(pos, 0, "only options");
if (f("--out") === null) throw usage("snapshot needs --out DIR");
return snapshot(opts, f("--out"));
case "unlock":
allow(flags, ["--check-gate"]); positional(pos, 0, "no arguments");
return unlock(opts, { checkGateOnly: flags.has("--check-gate") });
default:
throw usage(`unknown verb ${JSON.stringify(verb)}\n${USAGE}`);
}
}
export function main(argv) {
let res;
try {
res = run(argv);
} catch (err) {
if (err instanceof QueueError) {
process.stderr.write(`queue: ${err.message}\n`);
return err.code;
}
process.stderr.write(`queue: ${err?.stack ?? err}\n`);
return 1;
}
for (const line of res.err) process.stderr.write(`${line}\n`);
for (const line of res.out) process.stdout.write(`${line}\n`);
return res.code;
}
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
process.exitCode = main(process.argv.slice(2));
}