Files
stack/packages/queue/src/cli.mjs
T
jason.woltjeandClaude Opus 5.5 f539466fcb feat(queue): Piece D, reviews as issue comments, raw per-seat token helper (row 12, #1508)
queue move ID in-review posts the review request as a Gitea comment and
review record reads verdicts back, so reviews stop being files in
docs/plans/reviews/. On a comment round, in-review to waiting-on-jason
now needs every listed reviewer's approval for the current round, the
same as in-review to done (Filbert r1 C1). scripts/gitea-api.sh reads
the raw per-seat token files (lead decisions 37 to 39): config built and
checked before curl starts, export attribute cleared, fixed base URL.
test-queue.sh skips its live checks outside the canonical root.

Darkwing authored. Filbert approved D r2 (cf1d3fd0) after r1 (a2dc2302)
and corrected the plan (293747cd). Rocko reviewed the helper (e896192f,
2096b0a3), and Sage's lead check passed under decision 38. Manifest
b402fb38, 19 files.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 10:07:29 -05:00

253 lines
12 KiB
JavaScript

#!/usr/bin/env node
// 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]
// [--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
// review request ID | review resolve ID ATTEMPT --comment N
// review abandon ID ATTEMPT --reason TEXT --yes
// review record ID --verdict approve|changes --comment N --candidate DIGEST
// Each needs --op ID and an actor (--by NAME or $MOSAIC_AGENT_NAME).
// Read only: review verify-commit ID REF
// 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; for a request, not
// known to be posted); 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, verifyCommit, 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 review request ID --op ID | review resolve ID ATTEMPT --comment N --op ID",
" queue review abandon ID ATTEMPT --reason TEXT --yes --op ID",
" queue review record ID --verdict approve|changes --comment N --candidate DIGEST --op ID",
" queue review verify-commit ID REF",
" 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",
"--verdict", "--comment",
]);
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);
}
// Gitea comment ids outgrow a row id's seven digits.
function commentArg(v) {
if (v === null) throw usage("--comment N is required");
if (!/^[1-9][0-9]{0,15}$/.test(v) || !Number.isSafeInteger(Number(v))) throw usage(`--comment must be a comment id: ${JSON.stringify(v)}`);
return Number(v);
}
function review(pos, flags, f, change, opts) {
const [sub, ...rest] = pos;
switch (sub) {
case "request":
allow(flags, CHANGE); positional(rest, 1, "review request ID");
return change("review-request", { id: intArg(rest[0], "ID") });
case "resolve":
allow(flags, [...CHANGE, "--comment"]); positional(rest, 2, "review resolve ID ATTEMPT");
return change("review-resolve", { id: intArg(rest[0], "ID"), attempt: rest[1], comment: commentArg(f("--comment")) });
case "abandon":
allow(flags, [...CHANGE, "--reason", "--yes"]); positional(rest, 2, "review abandon ID ATTEMPT");
if (f("--reason") === null) throw usage("review abandon needs --reason");
return change("review-abandon", { id: intArg(rest[0], "ID"), attempt: rest[1], reason: f("--reason") }, { yes: flags.has("--yes") });
case "record":
allow(flags, [...CHANGE, "--verdict", "--comment", "--candidate"]); positional(rest, 1, "review record ID");
for (const k of ["--verdict", "--candidate"]) if (f(k) === null) throw usage(`review record needs ${k}`);
return change("review-record", { id: intArg(rest[0], "ID"), verdict: f("--verdict"), comment: commentArg(f("--comment")), candidate: f("--candidate") });
case "verify-commit":
allow(flags, []); positional(rest, 2, "review verify-commit ID REF");
return verifyCommit(opts, intArg(rest[0], "ID"), rest[1]);
default:
throw usage("review takes request, resolve, abandon, record or verify-commit");
}
}
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 "review":
return review(pos, flags, f, change, opts);
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));
}