feat(slice-21 FR-4a): rig queue create gains --body-file <path> + stdin, killing backtick-shell-corruption class (OPR.0.3.2.21.FR-4)

Closes FR-4(a) — the load-bearing fix in the queue ergonomics
slice. Operators authoring per-commit handoffs / re-verify
qitems with multi-line bodies that contain backtick fences
have had to use the temp-file pattern
(`--body "$(cat /tmp/f.txt)"`) to avoid shell substitution
mangling the body. Even with the pattern, operators
occasionally typed an inline `--body "$(...)"` and saw the
backticks vanish or the qitem reject with garbled content.

After this commit the temp-file pattern is no longer needed for
queue create: pass the path directly with `--body-file <path>`
and the file content is read as utf-8 verbatim. No shell
substitution; no backtick mangling; flag-like tokens inside
the body stay intact.

Two new input shapes on the create surface:

  rig queue create ... --body-file /tmp/handoff-body.txt
  rig queue create ... --body-file -        # stdin
  rig queue create ... --body -             # stdin (legacy --body honors -)

Mutual exclusion: passing both `--body` and `--body-file`
yields a 3-part fact/consequence/action error before the
daemon is contacted. Passing neither also yields the
3-part error (previously `--body` was a requiredOption which
errored at the Commander level; the validation moves into
the body-resolver so the 3-part error shape is consistent
across both classes).

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

- Exported resolveQueueBody(opts, stdinReader?) — pure
  function returning the body string for inline / file /
  stdin shapes; throws a 3-part error on conflict / missing
  / missing-file. stdinReader is dependency-injected with a
  defaultStdinReader implementation so tests can swap it
  without touching process.stdin.
- defaultStdinReader honors process.stdin.isTTY by resolving
  to empty (caller's empty-body check converts that to the
  3-part error rather than blocking forever on a TTY).
- emitBodyResolveError prints the 3-part error in JSON or
  stderr-prose form and sets process.exitCode = 1.
- The create command's action handler calls resolveQueueBody
  BEFORE withClient, so a missing/ambiguous/missing-file
  body fails fast and locally with no daemon round-trip.

The legacy `--body <text>` flag is preserved (no
backward-compat break for the common case of small inline
bodies); the change is purely additive on the input layer.

Tests (packages/cli/test/queue.test.ts +148):

A new "FR-4(a)" describe block with 10 tests:

- resolveQueueBody returns inline body for --body case.
- resolveQueueBody reads file content for --body-file path.
  Discriminator: assert raw backticks survive in the file
  content — proves the corruption class is gone.
- 3-part error when both --body and --body-file are passed.
- 3-part error when neither is passed.
- 3-part error when --body-file points at a missing path.
- Injected stdin reader is called for --body -.
- Injected stdin reader is called for --body-file -.
- Integration via parseAsync: create --body-file with
  backticks fences in content reaches the daemon as body;
  discriminator asserts the backtick fence pattern survived
  intact end-to-end.
- Integration: both --body and --body-file → exit 1 + no
  daemon contact.
- Integration: neither → exit 1 + no daemon contact.

Existing 13 queue tests still pass.

Verification:
- packages/cli queue.test.ts: 23/23 PASS (13 existing + 10 new).
- packages/cli tsc EXIT=0.
- Commit body verification grep clean per banked discipline.
- git diff --cached --stat: 2 files (+241 / -3).

Out of scope for this commit (lands as subsequent FR-4
commits on this branch): --mission / --slice first-class
flags (b); lastNudgeResult wording rename (c);
closure ≠ acceptance docs (d-docs). Schema change for a
distinct "accepted" state is deferred to 0.3.3 per the
mission's split note.

Branch: slice-21-fr-4-queue-ergonomics off LOCAL main
58713e0c. LOCAL only, NO push.
This commit is contained in:
mvschwarz committed 2026-06-01 05:30:07 -07:00
1 parent 58713e0c58
commit 2f731b8410
2 files changed
+241 -3

No files matched your search

+93 -3
View File
@@ -1,3 +1,4 @@
import fs from "node:fs";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
@@ -41,6 +42,84 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
// OPR.0.3.2.21.FR-4(a) — body input resolution. Three accepted shapes:
// --body "<text>" inline (legacy; backtick-prone for raw
// multiline content)
// --body-file <path> read body content from a file path
// (kills the backtick-corruption class)
// --body-file - or --body - read body from stdin (pipeline-friendly)
//
// Exactly one of --body / --body-file must be provided; the resolver throws
// a 3-part fact/consequence/action error otherwise.
//
// stdinReader is dependency-injected so tests can swap it without touching
// process.stdin. Default reads UTF-8 from process.stdin until EOF.
export interface ResolveBodyOpts {
body?: string;
bodyFile?: string;
}
export async function defaultStdinReader(): Promise<string> {
return new Promise<string>((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk: string) => { data += chunk; });
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", reject);
if (process.stdin.isTTY) {
// Resolve immediately to empty if nothing is piped; the caller's
// empty-body check turns this into a 3-part error.
resolve("");
}
});
}
export async function resolveQueueBody(
opts: ResolveBodyOpts,
stdinReader: () => Promise<string> = defaultStdinReader,
): Promise<string> {
const hasInline = opts.body !== undefined && opts.body !== "";
const hasFile = opts.bodyFile !== undefined && opts.bodyFile !== "";
if (hasInline && hasFile) {
const err = new Error("--body and --body-file are mutually exclusive.") as Error & { fact?: string; consequence?: string; action?: string };
err.fact = "Both --body and --body-file were passed; the body source is ambiguous.";
err.consequence = "rig queue create did not run; daemon was not contacted.";
err.action = "Pass exactly one of --body or --body-file.";
throw err;
}
if (!hasInline && !hasFile) {
const err = new Error("Missing required body input.") as Error & { fact?: string; consequence?: string; action?: string };
err.fact = "Neither --body nor --body-file was provided.";
err.consequence = "rig queue create did not run; daemon was not contacted.";
err.action = "Pass the qitem body via --body \"<text>\" or --body-file <path> (use - for stdin).";
throw err;
}
if (hasInline) {
if (opts.body === "-") return stdinReader();
return opts.body!;
}
// hasFile path
if (opts.bodyFile === "-") return stdinReader();
const absPath = opts.bodyFile!;
if (!fs.existsSync(absPath)) {
const err = new Error(`--body-file path does not exist: ${absPath}`) as Error & { fact?: string; consequence?: string; action?: string };
err.fact = `--body-file path does not exist: ${absPath}`;
err.consequence = "rig queue create did not run; daemon was not contacted.";
err.action = "Check the path; pass an absolute path; or use --body-file - to read from stdin.";
throw err;
}
return fs.readFileSync(absPath, "utf8");
}
function emitBodyResolveError(err: Error & { fact?: string; consequence?: string; action?: string }, json: boolean): void {
if (json) {
console.log(JSON.stringify({ ok: false, error: { fact: err.fact ?? err.message, consequence: err.consequence ?? "", action: err.action ?? "" } }, null, 2));
} else {
process.stderr.write(`Error: ${err.fact ?? err.message}\n${err.consequence ?? ""}\n${err.action ?? ""}\n`);
}
process.exitCode = 1;
}
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
if (session) return session;
@@ -62,7 +141,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.description("Create a new qitem")
.requiredOption("--source <session>", "Source session")
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
.requiredOption("--body <text>", "Qitem body")
.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("--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")
@@ -74,7 +154,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
.action(async (opts: {
source: string;
destination: string;
body: string;
body?: string;
bodyFile?: string;
priority: string;
tier?: string;
tags?: string;
@@ -84,6 +165,15 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
nudge?: boolean;
json?: boolean;
}) => {
// OPR.0.3.2.21.FR-4(a) — resolve body BEFORE contacting the daemon
// so a missing/ambiguous body fails fast and locally.
let resolvedBody: string;
try {
resolvedBody = await resolveQueueBody({ body: opts.body, bodyFile: opts.bodyFile });
} catch (err) {
emitBodyResolveError(err as Error & { fact?: string; consequence?: string; action?: string }, opts.json ?? false);
return;
}
const deps = getDeps();
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
await withClient(deps, async (client) => {
@@ -91,7 +181,7 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
qitemId: opts.id,
sourceSession: opts.source,
destinationSession: opts.destination,
body: opts.body,
body: resolvedBody,
priority: opts.priority,
tier: opts.tier,
tags,
+148
View File
@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { QueueDeps } from "../src/commands/queue.js";
import { resolveQueueBody } from "../src/commands/queue.js";
import { createProgram } from "../src/index.js";
/**
@@ -134,6 +138,150 @@ describe("rig queue CLI", () => {
expect((create!.body as { nudge: boolean }).nudge).toBe(false);
});
// OPR.0.3.2.21.FR-4(a) — body input resolution kills the
// backtick-corruption class. Three accepted shapes: --body inline,
// --body-file <path>, --body / --body-file - for stdin. Exactly one
// of --body / --body-file is required; mutual exclusion validates.
describe("FR-4(a) — --body-file + stdin support", () => {
it("resolveQueueBody returns inline body when --body is passed", async () => {
const out = await resolveQueueBody({ body: "inline value" });
expect(out).toBe("inline value");
});
it("resolveQueueBody reads from a file path when --body-file is passed", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-body-"));
const bodyPath = path.join(tmp, "body.txt");
const content = "Multi-line body with `raw backticks` and\nliteral newlines\n— this is the corruption class --body-file kills.";
fs.writeFileSync(bodyPath, content, "utf8");
try {
const out = await resolveQueueBody({ bodyFile: bodyPath });
expect(out).toBe(content);
// Discriminator: the backtick-corruption shell class is bypassed
// entirely because no shell substitution happens on file content.
expect(out).toMatch(/`raw backticks`/);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("resolveQueueBody throws 3-part error when both --body and --body-file are passed", async () => {
await expect(resolveQueueBody({ body: "inline", bodyFile: "/tmp/x" })).rejects.toMatchObject({
fact: expect.stringMatching(/mutually exclusive|ambiguous/i),
consequence: expect.stringMatching(/did not run/),
action: expect.stringMatching(/exactly one/),
});
});
it("resolveQueueBody throws 3-part error when neither --body nor --body-file is passed", async () => {
await expect(resolveQueueBody({})).rejects.toMatchObject({
fact: expect.stringMatching(/Neither --body nor --body-file/),
consequence: expect.stringMatching(/did not run/),
action: expect.stringMatching(/--body|--body-file/),
});
});
it("resolveQueueBody throws 3-part error when --body-file path does not exist", async () => {
await expect(resolveQueueBody({ bodyFile: "/tmp/this-path-does-not-exist-fr4a-test.md" })).rejects.toMatchObject({
fact: expect.stringMatching(/does not exist/),
consequence: expect.stringMatching(/did not run/),
action: expect.stringMatching(/Check the path/),
});
});
it("resolveQueueBody calls the injected stdin reader when --body is -", async () => {
const stdinReader = vi.fn(async () => "from stdin\n");
const out = await resolveQueueBody({ body: "-" }, stdinReader);
expect(out).toBe("from stdin\n");
expect(stdinReader).toHaveBeenCalledTimes(1);
});
it("resolveQueueBody calls the injected stdin reader when --body-file is -", async () => {
const stdinReader = vi.fn(async () => "from stdin file dash\n");
const out = await resolveQueueBody({ bodyFile: "-" }, stdinReader);
expect(out).toBe("from stdin file dash\n");
expect(stdinReader).toHaveBeenCalledTimes(1);
});
it("create --body-file <file-with-backticks> POSTs the file content as body (operator-copy-paste-safe)", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-body-file-"));
const bodyPath = path.join(tmp, "body.txt");
const content = "Per-commit handoff for OPR.X.Y.Z\n\n```bash\nrig queue handoff qitem-1 --to next@rig\n```\n\nDone.";
fs.writeFileSync(bodyPath, content, "utf8");
try {
const { deps, calls } = makeDeps({
routes: { "POST /api/queue/create": { status: 201, data: { qitemId: "qitem-fr4a-1", state: "pending" } } },
});
const program = createProgram({ queueDeps: deps });
program.exitOverride();
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--body-file", bodyPath,
"--json",
]);
const create = calls.find((c) => c.path === "/api/queue/create");
expect(create, "expected POST /api/queue/create to fire").toBeDefined();
const body = create!.body as Record<string, unknown>;
expect(body.body).toBe(content);
// Discriminator: the backtick fence survived intact, proving the
// shell-substitution class never touched the content.
expect((body.body as string)).toMatch(/```bash[\s\S]*?```/);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("create with both --body and --body-file errors with exit 1 + 3-part error + does NOT contact the daemon", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "queue-create-conflict-"));
const bodyPath = path.join(tmp, "body.txt");
fs.writeFileSync(bodyPath, "x", "utf8");
try {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
const prevExit = process.exitCode;
process.exitCode = undefined;
try {
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--body", "inline",
"--body-file", bodyPath,
"--json",
]);
expect(process.exitCode).toBe(1);
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
} finally {
process.exitCode = prevExit;
}
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
it("create with neither --body nor --body-file errors with exit 1 + does NOT contact the daemon", async () => {
const { deps, calls } = makeDeps();
const program = createProgram({ queueDeps: deps });
program.exitOverride();
const prevExit = process.exitCode;
process.exitCode = undefined;
try {
await program.parseAsync([
"node", "rig", "queue", "create",
"--source", "alice@rig",
"--destination", "bob@rig",
"--json",
]);
expect(process.exitCode).toBe(1);
expect(calls.find((c) => c.path === "/api/queue/create")).toBeUndefined();
} finally {
process.exitCode = prevExit;
}
});
});
it("update --state done WITHOUT --closure-reason renders structured hot-potato error and exits non-zero", async () => {
const { deps } = makeDeps({
routes: {