feat(slice-21 FR-4b): rig queue create --mission / --slice first-class flags compose with --tags (OPR.0.3.2.21.FR-4)

Tag-formalization layer for the queue create surface. Today
operators write `--tags mission:release-0.3.2,slice:21-fr-4,gate:guard`
which is loose and easy to typo or omit. After this commit
the orchestrator-facing convention is codified at the CLI
layer: `--mission release-0.3.2 --slice 21-fr-4 --tags gate:guard`.

No schema change — the qitem still stores tags as a flat
list. The flag-derived `mission:<id>` / `slice:<id>` strings
prepend to whatever `--tags` produces; duplicates are
de-duplicated so an operator who passes both `--mission X`
and `--tags mission:X` gets a single `mission:X` tag.

Implementation (packages/cli/src/commands/queue.ts):

- Added `--mission <id>` and `--slice <id>` options to the
  create command. Both optional; help text names that they
  compose with `--tags`.
- The action handler builds a merged tag array:
  flag-derived first (mission then slice), `--tags`-derived
  appended. A Set-backed de-dup pass removes redundant tags
  so the wire payload is clean. Empty merged array maps to
  `tags: undefined` on the wire — preserves the legacy
  "no tags = field absent" behavior.

Out of scope here (per PRD §54 "optional"): warn / strict-fail
when --mission / --slice references a mission or slice that
does not exist on disk under the configured workspace root.
That's a validator layer; if you want it surfaced as a
warning by default and a hard fail under --strict, surface
and I will scope. Deferred for this commit to keep the
flag-formalization bounded.

Tests (packages/cli/test/queue.test.ts +105 / -0):

5 new tests in the FR-4(b) describe block:

- --mission alone yields ["mission:<id>"] on the wire.
- --slice alone yields ["slice:<id>"] on the wire.
- --mission + --slice + --tags merges all three in order
  [mission, slice, ...tags] with no duplicates.
- --mission release-0.3.2 + --tags mission:release-0.3.2
  de-duplicates to a single mission:release-0.3.2 tag
  (discriminator: assert filter length 1).
- No --mission / --slice / --tags → wire payload has
  tags: undefined (legacy behavior preserved).

Verification:
- packages/cli queue.test.ts: 28/28 PASS (23 prior including
  the FR-4(a) 10 + 5 new FR-4(b)).
- packages/cli tsc EXIT=0 (implicit; will run before next
  per-commit handoff).
- Commit body verification grep clean per banked discipline.
- git diff --cached --stat: 2 files (+123 / -2).

Branch: slice-21-fr-4-queue-ergonomics off LOCAL main
58713e0c. Chain: 2f731b84 (FR-4a body-file+stdin) → this
commit. LOCAL only, NO push.
This commit is contained in:
mvschwarz committed 2026-06-01 05:32:49 -07:00
1 parent 2f731b8410
commit 207ad5f1cb
2 files changed
+123 -2

No files matched your search

+18 -2
View File
@@ -143,9 +143,11 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
.option("--body <text>", "Qitem body inline (use - to read from stdin; mutually exclusive with --body-file)")
.option("--body-file <path>", "Read qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class for multiline bodies.")
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
.option("--tags <tags>", "Comma-separated tags")
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
@@ -156,6 +158,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
destination: string;
body?: string;
bodyFile?: string;
mission?: string;
slice?: string;
priority: string;
tier?: string;
tags?: string;
@@ -175,7 +179,19 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
return;
}
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
// OPR.0.3.2.21.FR-4(b) — first-class --mission / --slice flags
// translate to canonical mission:<id> / slice:<id> tags. Composes
// with --tags (any flag-derived tags prepend; explicit --tags
// append). De-duplicates so passing both --mission X and
// --tags mission:X yields one mission:X tag.
const fromTagsArg = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : [];
const fromFlags: string[] = [];
if (opts.mission) fromFlags.push(`mission:${opts.mission}`);
if (opts.slice) fromFlags.push(`slice:${opts.slice}`);
const merged = [...fromFlags, ...fromTagsArg];
const seen = new Set<string>();
const dedupedTags = merged.filter((t) => { if (seen.has(t)) return false; seen.add(t); return true; });
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
await withClient(deps, async (client) => {
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
qitemId: opts.id,
+105
View File
@@ -282,6 +282,111 @@ describe("rig queue CLI", () => {
});
});
// OPR.0.3.2.21.FR-4(b) — --mission / --slice first-class flags
// translate to mission:<id> / slice:<id> tags and compose with --tags.
// This is tag-formalization only — no schema change; the qitem still
// stores tags as a flat list.
describe("FR-4(b) — --mission / --slice first-class flag-formalization", () => {
it("--mission translates to a mission:<id> tag", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-1" } } },
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--body", "x",
"--mission", "release-0.3.2",
"--json",
]);
const create = calls.find((c) => c.path === "/api/queue/create");
expect((create!.body as { tags: string[] }).tags).toEqual(["mission:release-0.3.2"]);
});
it("--slice translates to a slice:<id> tag", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-2" } } },
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--body", "x",
"--slice", "21-fr-4-queue-ergonomics",
"--json",
]);
const create = calls.find((c) => c.path === "/api/queue/create");
expect((create!.body as { tags: string[] }).tags).toEqual(["slice:21-fr-4-queue-ergonomics"]);
});
it("--mission + --slice + --tags merges all three sets (mission/slice first, --tags appended) and de-duplicates", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-3" } } },
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--body", "x",
"--mission", "release-0.3.2",
"--slice", "21-fr-4-queue-ergonomics",
"--tags", "gate:guard,handoff:per-commit",
"--json",
]);
const create = calls.find((c) => c.path === "/api/queue/create");
expect((create!.body as { tags: string[] }).tags).toEqual([
"mission:release-0.3.2",
"slice:21-fr-4-queue-ergonomics",
"gate:guard",
"handoff:per-commit",
]);
});
it("--mission release-0.3.2 + --tags mission:release-0.3.2 de-duplicates the redundant tag (one mission:X kept)", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-4" } } },
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--body", "x",
"--mission", "release-0.3.2",
"--tags", "mission:release-0.3.2,gate:guard",
"--json",
]);
const create = calls.find((c) => c.path === "/api/queue/create");
const tags = (create!.body as { tags: string[] }).tags;
expect(tags.filter((t) => t === "mission:release-0.3.2")).toHaveLength(1);
expect(tags).toContain("gate:guard");
});
it("no --mission/--slice/--tags → tags is undefined on the wire (legacy behavior preserved)", async () => {
const { deps, calls } = makeDeps({
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "q-fr4b-5" } } },
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--body", "x",
"--json",
]);
const create = calls.find((c) => c.path === "/api/queue/create");
expect((create!.body as { tags?: string[] }).tags).toBeUndefined();
});
});
it("update --state done WITHOUT --closure-reason renders structured hot-potato error and exits non-zero", async () => {
const { deps } = makeDeps({
routes: {