Files
stack/packages/queue/src/cli.mjs
T
jason.woltjeandClaude Opus 5.5 056a20397d queue: queue add by jason or sage requires --gate-owner (#1595)
Row 101. Built by a Haiku subagent under Researcher (decision 129),
packet agents/researcher/work/queue-101/r1/, candidate manifest
ee1335a6e44610f676ade116733d180ac6f0b3603a58306110cbd1dd1e9c1f69.
Filbert approved on #1595, comment 28245.

The check runs at write time in store.mjs mutateAs, with no SEMANTICS
bump, so the live Console keeps reading the log.

Sage's gate rerun on 79e126be0 with rows 101 and 105 applied, Docker
up: node suites bus 128, business 67, cli 146, control-board 124,
conversation 182, discord 178, harness 70, ledger 78, mosaic 69,
queue 155, runs 43, seat 109, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 16:26:12 -05:00

255 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]
// (jason and sage must pass --gate-owner; see the usage text)
// 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]",
" jason and sage must pass --gate-owner: a privileged add has no default gate owner. Other seats can't set it; their rows get jason",
" 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));
}