merge: slice 01 — workspace-workflow GA (release-0.3.2)

Second 0.3.2 triple-gate completion. Workspace + workflow GA coherence
(HG-1..4), CLI-side --max-files/--exit strict validation (HG-6, no
silent coercion), plus QA-found conveyor starter launch-cycle fix
(reviewer->builder delegates_to -> can_observe; acyclic starter-spec
regression added).

driver (velocity-driver) -> guard ACCEPT @ 2b4772c7 -> velocity-qa
HG-7 fresh-install dogfood CLEAR (found+fixed the launch cycle in-loop)
-> guard delta-review CLEAR @ shipping tip f3449baf (discriminator
make-it-fail-first verified; daemon starter 25/25, CLI 28/28,
workflow-contract 43/43, build PASS under Node 22.22.1). Orch heavy
pre-merge verify passed. Local main only — no push.

GA-bar is a FOUNDER gate (not self-certified here). HG-5 PARTIAL ->
release-0.3.3 follow-on filed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-17 20:32:49 -07:00
commit 7a12c930b6
9 files changed
+732 -11

No files matched your search

@@ -39,6 +39,11 @@ export interface InitWorkspaceResult {
type DefaultSlice = {
id: string;
/** §1 dot-ID per conventions/scope-and-versioning (release-0.3.2
* slice 01 GA-coherence pass). Mirror of the daemon constant in
* packages/daemon/src/domain/workspace/default-workspace-scaffold.ts
* — keep in lockstep for the slice-21 parity test. */
dotId: string;
title: string;
status: "active" | "draft";
objective: string;
@@ -46,27 +51,36 @@ type DefaultSlice = {
type DefaultMission = {
id: string;
/** §1 mission dot-ID — escape band for non-release missions. */
dotId: string;
title: string;
status: "active" | "draft";
objective: string;
slices: DefaultSlice[];
};
// Release-0.3.2 slice 01 (OPR.0.3.2.1) — seeded missions/slices now
// carry §1 dot-IDs in frontmatter so the scaffold coheres with
// `conventions/scope-and-versioning`. Mirror of the daemon constant;
// the slice-21 parity test asserts both sides emit identical bodies.
const DEFAULT_MISSIONS: DefaultMission[] = [
{
id: "getting-started",
dotId: "OPR.99.0.1",
title: "Getting Started",
status: "active",
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
slices: [
{
id: "first-conveyor-run",
dotId: "OPR.99.0.1.1",
title: "First Conveyor Run",
status: "active",
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
},
{
id: "inspect-project-evidence",
dotId: "OPR.99.0.1.2",
title: "Inspect Project Evidence",
status: "draft",
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
@@ -93,7 +107,7 @@ const WORKSPACE_DIRS = [
function subdirReadmeContent(subdir: string): string {
switch (subdir) {
case "missions":
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-name>/README.md\nmissions/<mission-name>/PROGRESS.md\nmissions/<mission-name>/slices/<slice-name>/README.md\n```\n\nEvery mission and slice README carries a stable dot-ID in its frontmatter (`id: OPR.<ver>[.<n>]`) per the scope-and-versioning convention. Use `rig scope mission create <name>` and `rig scope slice create <mission> <slug>` to mint conformant artifacts; the CLI handles the dot-ID + auto-numbering for you. The folder name is the operator-facing slug; the `id:` is the stable handle that survives renames.\n";
case "artifacts":
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
case "evidence":
@@ -122,7 +136,7 @@ This workspace is file-backed. The Project UI mirrors this structure:
- \`evidence/\` is for proof notes and verification summaries.
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
Use stable kebab-case names for mission and slice folders. Every mission/slice README also carries a stable dot-ID in its frontmatter (\`id: OPR.<ver>[.<n>]\`) per the \`scope-and-versioning\` convention; mint conformant artifacts via \`rig scope mission create <name>\` and \`rig scope slice create <mission> <slug>\`. Folder names are operator-facing slugs; the \`id:\` is the rename-proof handle.
`;
const STEERING_PLACEHOLDER = `---
@@ -153,6 +167,7 @@ alongside your Project workspace.
function missionReadme(mission: DefaultMission): string {
return `---
id: ${mission.dotId}
title: ${mission.title}
status: ${mission.status}
mission: ${mission.id}
@@ -423,6 +438,7 @@ function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
if (narrative) {
return `---
id: ${slice.dotId}
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
@@ -433,6 +449,7 @@ slice: ${slice.id}
${narrative.readme}`;
}
return `---
id: ${slice.dotId}
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
+178 -5
View File
@@ -41,6 +41,50 @@ function printResult(json: boolean, body: unknown, status: number): void {
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
}
// release-0.3.2 slice 01 GA polish — every mutating workflow command
// ends with a 3-line what/state/next summary in human mode so the
// operator doesn't have to grep the raw JSON for the outcome. JSON
// mode keeps the daemon response verbatim — agent consumers parse
// the structured body, not the human summary.
export interface OutcomeSummary {
what: string;
state: string;
next: string;
}
// release-0.3.2 slice 01 BC repair — runtime validator for the
// project --exit enum. TypeScript types don't enforce at runtime;
// without this guard `--exit banana` would forward to the daemon
// transactional-scribe surface. Match the same 3-part error shape
// used elsewhere in the slice.
export const PROJECT_EXIT_KINDS = ["handoff", "waiting", "done", "failed"] as const;
export type ProjectExitKind = (typeof PROJECT_EXIT_KINDS)[number];
export function isProjectExitKind(value: unknown): value is ProjectExitKind {
return typeof value === "string" && (PROJECT_EXIT_KINDS as readonly string[]).includes(value);
}
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
if (json) {
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
} else {
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
}
process.exitCode = 1;
}
export function printOutcomeSummary(json: boolean, status: number, summary: OutcomeSummary | null): void {
if (json || !summary || status >= 400) return;
console.log("");
console.log(` what: ${summary.what}`);
console.log(` state: ${summary.state}`);
console.log(` next: ${summary.next}`);
}
function asString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
const cmd = new Command("workflow").description(
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
@@ -55,6 +99,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
.command("validate <specPath>")
.description("Validate a workflow spec file (returns structured ok/error report)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
$ rig workflow validate workflows/conveyor-starter.workflow.md
$ rig workflow validate ./my-spec.workflow.md --json | jq .ok
`)
.action(async (specPath: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
@@ -70,6 +119,17 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
.option("--entry-owner <session>", "Override default entry-step owner")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
$ rig workflow instantiate workflows/conveyor.workflow.md \\
--root-objective "Ship release-0.3.2" \\
--created-by orch-lead@openrig-velocity
$ rig workflow instantiate ./my-spec.workflow.md \\
--root-objective "Run dogfood" \\
--created-by velocity-driver@openrig-velocity \\
--entry-owner velocity-qa@openrig-velocity --json
`)
.action(async (specPath: string, opts: {
rootObjective: string;
createdBy: string;
@@ -78,13 +138,29 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
}) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/workflow/instantiate", {
const res = await client.post<{
instanceId?: string;
entryStepId?: string;
entryOwnerSession?: string;
status?: string;
}>("/api/workflow/instantiate", {
specPath,
rootObjective: opts.rootObjective,
createdBySession: opts.createdBy,
entryOwnerSession: opts.entryOwner,
});
printResult(opts.json ?? false, res.data, res.status);
const body = res.data ?? {};
const instanceId = asString(body.instanceId) ?? "(no instance id)";
const entryStepId = asString(body.entryStepId);
const owner = asString(body.entryOwnerSession) ?? opts.entryOwner ?? "(default from spec)";
printOutcomeSummary(opts.json ?? false, res.status, {
what: `Instantiated workflow from ${specPath} (instance ${instanceId})`,
state: `${body.status ?? "active"}; entry packet ${entryStepId ?? "pending"} owned by ${owner}`,
next: entryStepId
? `Inspect: rig workflow show ${instanceId} | Open packet: rig queue show ${entryStepId}`
: `Inspect: rig workflow show ${instanceId}`,
});
});
});
@@ -99,28 +175,82 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
.option("--blocked-on <ref>", "For waiting exits: blocker reference (qitem id, gate name)")
.option("--next-owner <session>", "Override default next-step owner")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
# handoff to the spec's default next step
$ rig workflow project \\
--instance WF01ABC \\
--current-packet QITEM-123 \\
--exit handoff \\
--actor-session velocity-driver@openrig-velocity \\
--result-note "implementation green; ready for review"
# close the run cleanly
$ rig workflow project --instance WF01ABC --current-packet QITEM-9 \\
--exit done --actor-session orch-lead@openrig-velocity
# block on an external gate
$ rig workflow project --instance WF01ABC --current-packet QITEM-4 \\
--exit waiting --actor-session velocity-qa@openrig-velocity \\
--blocked-on "founder-gate-2"
`)
.action(async (opts: {
instance: string;
currentPacket: string;
exit: "handoff" | "waiting" | "done" | "failed";
exit: string;
actorSession: string;
resultNote?: string;
blockedOn?: string;
nextOwner?: string;
json?: boolean;
}) => {
// HG-6 — runtime-validate the --exit enum BEFORE the daemon
// call. TypeScript types are erased at runtime; without this
// guard `--exit banana` would forward to the transactional
// workflow surface.
if (!isProjectExitKind(opts.exit)) {
emit3PartError(
Boolean(opts.json),
`--exit must be one of ${PROJECT_EXIT_KINDS.join(" | ")} (got "${opts.exit}").`,
"rig workflow project did not run; daemon was not contacted.",
`Pass a valid exit kind. Example: --exit handoff.`,
);
return;
}
const exitKind: ProjectExitKind = opts.exit;
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>("/api/workflow/project", {
const res = await client.post<{
closedPacketId?: string;
nextPacketId?: string;
nextOwnerSession?: string;
instanceStatus?: string;
}>("/api/workflow/project", {
instanceId: opts.instance,
currentPacketId: opts.currentPacket,
exit: opts.exit,
exit: exitKind,
actorSession: opts.actorSession,
resultNote: opts.resultNote,
blockedOn: opts.blockedOn,
nextOwnerSession: opts.nextOwner,
});
printResult(opts.json ?? false, res.data, res.status);
const body = res.data ?? {};
const closedId = asString(body.closedPacketId) ?? opts.currentPacket;
const nextId = asString(body.nextPacketId);
const nextOwner = asString(body.nextOwnerSession) ?? opts.nextOwner ?? "(default from spec)";
const status = body.instanceStatus ?? (opts.exit === "done" ? "completed" : opts.exit === "failed" ? "failed" : "active");
const whatTail = nextId ? ` and projected ${nextId} to ${nextOwner}` : (opts.exit === "done" ? " (instance done)" : "");
const nextAction = nextId
? `Inspect: rig queue show ${nextId}`
: (opts.exit === "done" || opts.exit === "failed")
? `Inspect: rig workflow show ${opts.instance}`
: `Wait for upstream; rig workflow show ${opts.instance} to monitor`;
printOutcomeSummary(opts.json ?? false, res.status, {
what: `Closed ${closedId} (${opts.exit})${whatTail}`,
state: `instance ${opts.instance} = ${status}`,
next: nextAction,
});
});
});
@@ -129,6 +259,12 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
.description("List workflow instances; optionally filter by status")
.option("--status <s>", "Filter by status (active | waiting | completed | failed)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
$ rig workflow list
$ rig workflow list --status active --json
$ rig workflow list --status waiting | jq -r '.instances[].instanceId'
`)
.action(async (opts: { status?: string; json?: boolean }) => {
const deps = getDeps();
const params = new URLSearchParams();
@@ -150,6 +286,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
.command("specs")
.description("List registered workflow specs; built-in starters tagged with (built-in)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
$ rig workflow specs
$ rig workflow specs --json | jq '.specs[] | select(.isBuiltIn==false)'
`)
.action(async (opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
@@ -185,6 +326,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
.command("show <instanceId>")
.description("Show one workflow instance")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
$ rig workflow show WF01ABC
$ rig workflow show WF01ABC --json | jq '.instance.status'
`)
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
@@ -197,6 +343,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
.command("trace <instanceId>")
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
$ rig workflow trace WF01ABC
$ rig workflow trace WF01ABC --json | jq '.trail[] | {step, actor, exit}'
`)
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
@@ -209,11 +360,33 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
.command("continue <instanceId>")
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
.option("--json", "JSON output for agents")
.addHelpText("after", `
Examples:
$ rig workflow continue WF01ABC
$ rig workflow continue WF01ABC --json
`)
.action(async (instanceId: string, opts: { json?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const res = await client.post<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
const res = await client.post<{
advanced?: boolean;
nextPacketId?: string;
nextOwnerSession?: string;
instanceStatus?: string;
}>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
printResult(opts.json ?? false, res.data, res.status);
const body = res.data ?? {};
const advanced = body.advanced !== false;
const nextId = asString(body.nextPacketId);
const nextOwner = asString(body.nextOwnerSession) ?? "(unknown)";
const status = body.instanceStatus ?? "(unknown)";
printOutcomeSummary(opts.json ?? false, res.status, {
what: advanced
? `Advanced instance ${instanceId}${nextId ? ` to packet ${nextId}` : ""}`
: `No advance for instance ${instanceId} (already at frontier or terminal)`,
state: `instance status = ${status}${nextId ? `; next owner = ${nextOwner}` : ""}`,
next: nextId ? `Inspect: rig queue show ${nextId}` : `Inspect: rig workflow trace ${instanceId}`,
});
});
});
+38 -1
View File
@@ -46,6 +46,32 @@ async function withClient<T>(
return fn(client);
}
// release-0.3.2 slice 01 BC repair — strict-int validator for
// --max-files. Rejects `12abc`, `abc`, `0`, `-1`, etc. with a 3-part
// fact/consequence/action error; does NOT call the daemon on invalid
// input. Keeps positive cases (`10000`, `12`, etc.) flowing through.
export function parseMaxFilesStrict(raw: string): number {
if (!/^[1-9][0-9]*$/.test(raw)) {
const err = new Error(
`--max-files must be a positive integer (got "${raw}").`,
) as Error & { fact: string; consequence: string; action: string };
err.fact = `--max-files must be a positive integer (got "${raw}").`;
err.consequence = "rig workspace validate did not run; daemon was not contacted.";
err.action = "Pass a positive integer like --max-files 10000.";
throw err;
}
return Number.parseInt(raw, 10);
}
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
if (json) {
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
} else {
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
}
process.exitCode = 1;
}
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
const cmd = new Command("workspace").description(
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
@@ -78,6 +104,17 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
json?: boolean;
},
) => {
// HG-6 — CLI-side validation BEFORE the daemon call. Reject
// malformed --max-files with a 3-part error; never silently
// coerce `12abc` → 12.
let maxFiles: number;
try {
maxFiles = parseMaxFilesStrict(opts.maxFiles);
} catch (err) {
const e = err as Error & { fact?: string; consequence?: string; action?: string };
emit3PartError(Boolean(opts.json), e.fact ?? e.message, e.consequence ?? "", e.action ?? "");
return;
}
const root = path.resolve(rootArg ?? process.cwd());
const deps = getDeps();
await withClient(deps, async (client) => {
@@ -86,7 +123,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
workspaceKind: opts.kind,
recursive: opts.recursive !== false,
requireFrontmatter: opts.requireFrontmatter ?? false,
maxFiles: Number.parseInt(opts.maxFiles, 10),
maxFiles,
});
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
@@ -0,0 +1,142 @@
// release-0.3.2 slice 01 BC repair — HG-6 discriminators for the
// workflow project --exit enum. Without runtime validation, the
// TypeScript-only enum allowed `--exit banana` to flow through to
// the daemon transactional-scribe surface.
import { describe, expect, it, vi } from "vitest";
import {
workflowCommand,
isProjectExitKind,
PROJECT_EXIT_KINDS,
type WorkflowDeps,
} from "../src/commands/workflow.js";
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
function captureStdout(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
};
return { logs, restore: () => { console.log = original; } };
}
function captureStderr(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = process.stderr.write;
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
return { logs, restore: () => { process.stderr.write = original; } };
}
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkflowDeps {
return {
lifecycleDeps: {} as LifecycleDeps,
clientFactory: () => ({
post,
get: vi.fn(),
postText: vi.fn(),
}) as unknown as ReturnType<WorkflowDeps["clientFactory"]>,
};
}
describe("isProjectExitKind — pure helper", () => {
it("accepts every documented exit kind", () => {
for (const kind of PROJECT_EXIT_KINDS) {
expect(isProjectExitKind(kind)).toBe(true);
}
});
it("rejects garbage strings (BC-2 root cause)", () => {
expect(isProjectExitKind("banana")).toBe(false);
expect(isProjectExitKind("")).toBe(false);
expect(isProjectExitKind("HANDOFF")).toBe(false); // case-sensitive
});
it("rejects non-strings", () => {
expect(isProjectExitKind(undefined)).toBe(false);
expect(isProjectExitKind(null)).toBe(false);
expect(isProjectExitKind(42)).toBe(false);
});
});
describe("rig workflow project --exit — CLI-side discriminator (BLOCK 2)", () => {
it("rejects '--exit banana' with --json: exit=1, body.error 3-part, client.post NOT called", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", "banana",
"--actor-session", "x@y",
"--json",
]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = out.logs.join("\n");
const parsed = JSON.parse(joined);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/--exit must be one of handoff \| waiting \| done \| failed.*banana/);
expect(parsed.error.consequence).toMatch(/did not run/);
expect(parsed.error.action).toMatch(/--exit handoff/);
});
it("emits 3-part error to stderr in human mode", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const err = captureStderr();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", "banana",
"--actor-session", "x@y",
]);
err.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = err.logs.join("");
expect(joined).toMatch(/Error:.*--exit must be one of/);
expect(joined).toMatch(/banana/);
expect(joined).toMatch(/did not run/);
});
it("rejects each garbage variant ('', 'HANDOFF', 'shipped') without calling the daemon", async () => {
for (const bad of ["", "HANDOFF", "shipped"]) {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workflowCommand(deps);
await root.parseAsync([
"node", "rig", "project",
"--instance", "WF1",
"--current-packet", "q1",
"--exit", bad,
"--actor-session", "x@y",
"--json",
]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode, `--exit "${bad}" should fail`).toBe(1);
expect(post, `--exit "${bad}" must not call the daemon`).not.toHaveBeenCalled();
}
});
// Positive-path validation (e.g. --exit handoff forwards correctly)
// is covered by isProjectExitKind pure-helper above; the live action
// path is gated by getDaemonStatus and tested at the integration
// tier, mirroring the slice-01 workflow-ux test pattern.
});
+128
View File
@@ -0,0 +1,128 @@
// release-0.3.2 slice 01 (OPR.0.3.2.1) — GA-polish coverage for the
// rig workflow command surface.
//
// Scope: static --help + description coverage; the what/state/next
// summary behavior is verified at the building-block level via
// printOutcomeSummary unit tests (exported for this purpose), since
// the live action path is gated by getDaemonStatus and tested at the
// integration tier elsewhere.
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
workflowCommand,
printOutcomeSummary,
type OutcomeSummary,
} from "../src/commands/workflow.js";
describe("rig workflow --help carries examples on every command (HG-4)", () => {
const expectedVerbs = [
"validate",
"instantiate",
"project",
"list",
"specs",
"show",
"trace",
"continue",
];
// Commander v13 attaches `addHelpText('after', ...)` as a listener
// that fires when --help is rendered; `helpInformation()` returns
// Usage+Options+Subcommands but NOT the after-text. Source-scan the
// command file to prove every verb carries an Examples block —
// catches drift if a future refactor drops `addHelpText` from one
// verb but leaves the others.
const here = path.dirname(fileURLToPath(import.meta.url));
const workflowSrc = readFileSync(
path.resolve(here, "../src/commands/workflow.ts"),
"utf8",
);
for (const verb of expectedVerbs) {
it(`${verb} has an addHelpText('after', ...) block with an Examples section`, () => {
const root = workflowCommand();
const sub = root.commands.find((c) => c.name() === verb);
expect(sub, `verb ${verb} should be registered`).toBeTruthy();
// Source-scan: locate the command(...) declaration and look
// for an Examples block in the addHelpText that follows.
const verbBlockRe = new RegExp(
`\\.command\\("${verb}[ "(<]([\\s\\S]*?)\\.action\\(`,
"m",
);
const match = verbBlockRe.exec(workflowSrc);
expect(match, `couldn't locate builder for "${verb}"`).toBeTruthy();
const block = match![0];
expect(block).toMatch(/\.addHelpText\("after",/);
expect(block).toMatch(/Examples?:/i);
expect(block).toMatch(/\$ rig workflow /); // at least one concrete example invocation
});
}
it("every verb has a non-empty description", () => {
const root = workflowCommand();
for (const sub of root.commands) {
expect(
sub.description().length,
`verb ${sub.name()} needs a description`,
).toBeGreaterThan(0);
}
});
it("the top-level command itself is documented", () => {
const root = workflowCommand();
expect(root.description().length).toBeGreaterThan(0);
});
});
describe("printOutcomeSummary — what/state/next ending (HG-4)", () => {
function captureStdout(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
};
return { logs, restore: () => { console.log = original; } };
}
const baseSummary: OutcomeSummary = {
what: "Closed QITEM-123 (handoff) and projected QITEM-456 to velocity-qa@openrig-velocity",
state: "instance WF01ABC = active",
next: "Inspect: rig queue show QITEM-456",
};
it("emits 4 lines (blank + what + state + next) in human mode on success", () => {
const out = captureStdout();
printOutcomeSummary(false, 200, baseSummary);
out.restore();
const joined = out.logs.join("\n");
expect(joined).toMatch(/what:\s+Closed QITEM-123/);
expect(joined).toMatch(/state:\s+instance WF01ABC = active/);
expect(joined).toMatch(/next:\s+Inspect: rig queue show QITEM-456/);
// Blank separator line precedes the summary.
expect(out.logs[0]).toBe("");
});
it("suppresses output in --json mode (machine consumers stay clean)", () => {
const out = captureStdout();
printOutcomeSummary(true, 200, baseSummary);
out.restore();
expect(out.logs.length).toBe(0);
});
it("suppresses output when status >= 400 (error path uses its own surface)", () => {
const out = captureStdout();
printOutcomeSummary(false, 500, baseSummary);
out.restore();
expect(out.logs.length).toBe(0);
});
it("suppresses output when summary is null", () => {
const out = captureStdout();
printOutcomeSummary(false, 200, null);
out.restore();
expect(out.logs.length).toBe(0);
});
});
@@ -0,0 +1,130 @@
// release-0.3.2 slice 01 BC repair — HG-6 discriminators.
// Verifies that `rig workspace validate --max-files <garbage>` rejects
// CLI-side with a 3-part error AND does NOT call the daemon. Matches
// the guard BC-1 fix recipe.
import { describe, expect, it, vi } from "vitest";
import { workspaceCommand, parseMaxFilesStrict, type WorkspaceDeps } from "../src/commands/workspace.js";
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
function captureStdout(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = console.log;
console.log = (...args: unknown[]) => {
logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
};
return { logs, restore: () => { console.log = original; } };
}
function captureStderr(): { logs: string[]; restore: () => void } {
const logs: string[] = [];
const original = process.stderr.write;
process.stderr.write = ((chunk: unknown) => { logs.push(String(chunk)); return true; }) as typeof process.stderr.write;
return { logs, restore: () => { process.stderr.write = original; } };
}
function fakeRunningDeps(post: ReturnType<typeof vi.fn>): WorkspaceDeps {
return {
lifecycleDeps: {} as LifecycleDeps,
clientFactory: () => ({
post,
get: vi.fn(),
postText: vi.fn(),
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
};
}
describe("parseMaxFilesStrict — pure helper", () => {
it("accepts positive integers", () => {
expect(parseMaxFilesStrict("1")).toBe(1);
expect(parseMaxFilesStrict("12")).toBe(12);
expect(parseMaxFilesStrict("10000")).toBe(10000);
});
it("rejects garbage tails (BC-1 root cause)", () => {
expect(() => parseMaxFilesStrict("12abc")).toThrow(/positive integer/);
});
it("rejects non-numeric input", () => {
expect(() => parseMaxFilesStrict("abc")).toThrow(/positive integer/);
expect(() => parseMaxFilesStrict("")).toThrow(/positive integer/);
});
it("rejects zero and negatives", () => {
expect(() => parseMaxFilesStrict("0")).toThrow(/positive integer/);
expect(() => parseMaxFilesStrict("-1")).toThrow(/positive integer/);
});
it("3-part error carries fact + consequence + action fields", () => {
try {
parseMaxFilesStrict("12abc");
throw new Error("should have thrown");
} catch (err) {
const e = err as Error & { fact?: string; consequence?: string; action?: string };
expect(e.fact).toMatch(/positive integer/);
expect(e.consequence).toMatch(/did not run/);
expect(e.action).toMatch(/Pass a positive integer/);
}
});
});
describe("rig workspace validate --max-files — CLI-side discriminator (BLOCK 1)", () => {
it("rejects '12abc' with --json: exit=1, body.error has fact/consequence/action, client.post NOT called", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workspaceCommand(deps);
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc", "--json"]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = out.logs.join("\n");
const parsed = JSON.parse(joined);
expect(parsed.ok).toBe(false);
expect(parsed.error.fact).toMatch(/positive integer/);
expect(parsed.error.consequence).toMatch(/did not run/);
expect(parsed.error.action).toMatch(/--max-files/);
});
it("rejects 'abc', '0', '-1' equivalently", async () => {
for (const bad of ["abc", "0", "-1"]) {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const out = captureStdout();
const root = workspaceCommand(deps);
const originalExitCode = process.exitCode;
process.exitCode = 0;
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", bad, "--json"]);
out.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode, `--max-files ${bad} should fail`).toBe(1);
expect(post, `--max-files ${bad} must not call the daemon`).not.toHaveBeenCalled();
}
});
it("emits 3-part error to stderr in human mode (no --json)", async () => {
const post = vi.fn();
const deps = fakeRunningDeps(post);
const err = captureStderr();
const originalExitCode = process.exitCode;
process.exitCode = 0;
const root = workspaceCommand(deps);
await root.parseAsync(["node", "rig", "validate", "/tmp/ws", "--max-files", "12abc"]);
err.restore();
const exitCode = process.exitCode;
process.exitCode = originalExitCode;
expect(exitCode).toBe(1);
expect(post).not.toHaveBeenCalled();
const joined = err.logs.join("");
expect(joined).toMatch(/Error:.*positive integer/);
expect(joined).toMatch(/did not run/);
expect(joined).toMatch(/Pass a positive integer/);
});
// Positive case is covered by parseMaxFilesStrict pure-helper test
// above ("accepts positive integers"); driving end-to-end here
// requires stubbing the daemon-status check too, which is covered
// by the daemon-lifecycle adopt/expand test pattern (out of scope
// for this BC discriminator).
});
@@ -58,7 +58,7 @@ edges:
- kind: delegates_to
from: build.builder
to: review.reviewer
- kind: delegates_to
- kind: can_observe
from: review.reviewer
to: build.builder
- kind: can_observe
@@ -5,6 +5,11 @@ import { GETTING_STARTED_NARRATIVE } from "./getting-started-narrative.js";
type DefaultSlice = {
id: string;
/** §1 dot-ID per conventions/scope-and-versioning. Minted into the
* slice README frontmatter so the seeded scaffold satisfies the
* GA-convention coherence target without needing to round-trip
* through `rig scope`. */
dotId: string;
title: string;
status: "active" | "draft";
objective: string;
@@ -12,27 +17,37 @@ type DefaultSlice = {
type DefaultMission = {
id: string;
/** §1 mission dot-ID — escape band for non-release missions. */
dotId: string;
title: string;
status: "active" | "draft";
objective: string;
slices: DefaultSlice[];
};
// Release-0.3.2 slice 01 (OPR.0.3.2.1) — seeded missions/slices now
// carry §1 dot-IDs in frontmatter so the scaffold coheres with
// `conventions/scope-and-versioning`. `getting-started` is a
// non-release mission → escape band `OPR.99.0.1`; its slices fan out
// as `OPR.99.0.1.<n>` (numbers monotonic, never reused).
const DEFAULT_MISSIONS: DefaultMission[] = [
{
id: "getting-started",
dotId: "OPR.99.0.1",
title: "Getting Started",
status: "active",
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
slices: [
{
id: "first-conveyor-run",
dotId: "OPR.99.0.1.1",
title: "First Conveyor Run",
status: "active",
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
},
{
id: "inspect-project-evidence",
dotId: "OPR.99.0.1.2",
title: "Inspect Project Evidence",
status: "draft",
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
@@ -61,7 +76,7 @@ export function workspaceScaffoldDirs(): string[] {
function subdirReadmeContent(subdir: string): string {
switch (subdir) {
case "missions":
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-name>/README.md\nmissions/<mission-name>/PROGRESS.md\nmissions/<mission-name>/slices/<slice-name>/README.md\n```\n\nEvery mission and slice README carries a stable dot-ID in its frontmatter (`id: OPR.<ver>[.<n>]`) per the scope-and-versioning convention. Use `rig scope mission create <name>` and `rig scope slice create <mission> <slug>` to mint conformant artifacts; the CLI handles the dot-ID + auto-numbering for you. The folder name is the operator-facing slug; the `id:` is the stable handle that survives renames.\n";
case "artifacts":
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
case "evidence":
@@ -90,7 +105,7 @@ This workspace is file-backed. The Project UI mirrors this structure:
- \`evidence/\` is for proof notes and verification summaries.
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
Use stable kebab-case names for mission and slice folders. Every mission/slice README also carries a stable dot-ID in its frontmatter (\`id: OPR.<ver>[.<n>]\`) per the \`scope-and-versioning\` convention; mint conformant artifacts via \`rig scope mission create <name>\` and \`rig scope slice create <mission> <slug>\`. Folder names are operator-facing slugs; the \`id:\` is the rename-proof handle.
`;
const STEERING_PLACEHOLDER = `---
@@ -121,6 +136,7 @@ alongside your Project workspace.
function missionReadme(mission: DefaultMission): string {
return `---
id: ${mission.dotId}
title: ${mission.title}
status: ${mission.status}
mission: ${mission.id}
@@ -157,6 +173,7 @@ function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
if (narrative) {
return `---
id: ${slice.dotId}
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
@@ -167,6 +184,7 @@ slice: ${slice.id}
${narrative.readme}`;
}
return `---
id: ${slice.dotId}
title: ${slice.title}
status: ${slice.status}
mission: ${mission.id}
@@ -77,6 +77,74 @@ const STARTER_AGENT_SPECS = [
"agents/orchestration/orchestrator/agent.yaml",
];
function assertAcyclicLaunchDependencyGraph(file: string, parsed: Record<string, unknown>): void {
const pods = (parsed["pods"] as Array<Record<string, unknown>> | undefined) ?? [];
const allIds: string[] = [];
const inDegree: Record<string, number> = {};
const adjacency: Record<string, string[]> = {};
for (const pod of pods) {
const podId = String(pod["id"]);
const members = (pod["members"] as Array<Record<string, unknown>> | undefined) ?? [];
for (const member of members) {
const qid = `${podId}.${String(member["id"])}`;
allIds.push(qid);
inDegree[qid] = 0;
adjacency[qid] = [];
}
}
const addDependency = (from: string, to: string) => {
if (!adjacency[from] || !adjacency[to]) return;
adjacency[from]!.push(to);
inDegree[to] = (inDegree[to] ?? 0) + 1;
};
const addEdge = (edge: Record<string, unknown>, qualify?: (id: string) => string) => {
const kind = String(edge["kind"]);
if (kind !== "delegates_to" && kind !== "spawned_by") return;
const rawFrom = String(edge["from"]);
const rawTo = String(edge["to"]);
const from = qualify ? qualify(rawFrom) : rawFrom;
const to = qualify ? qualify(rawTo) : rawTo;
if (kind === "delegates_to") {
addDependency(from, to);
} else {
addDependency(to, from);
}
};
for (const pod of pods) {
const podId = String(pod["id"]);
const edges = (pod["edges"] as Array<Record<string, unknown>> | undefined) ?? [];
for (const edge of edges) {
addEdge(edge, (id) => `${podId}.${id}`);
}
}
const edges = (parsed["edges"] as Array<Record<string, unknown>> | undefined) ?? [];
for (const edge of edges) {
addEdge(edge);
}
const queue = allIds.filter((id) => inDegree[id] === 0).sort();
const visited: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
visited.push(current);
for (const next of (adjacency[current] ?? []).sort()) {
inDegree[next] = (inDegree[next] ?? 1) - 1;
if (inDegree[next] === 0) queue.push(next);
queue.sort();
}
}
if (visited.length !== allIds.length) {
const cycled = allIds.filter((id) => !visited.includes(id));
throw new Error(`${file} has a launch dependency cycle among: ${cycled.join(", ")}`);
}
}
describe("Starter specs", () => {
const specReviewService = new SpecReviewService();
@@ -203,6 +271,14 @@ describe("Starter specs", () => {
}
});
it("all rig specs have acyclic launch-dependency edges", () => {
for (const file of RIG_SPECS) {
const yaml = readFileSync(join(SPECS_ROOT, file), "utf-8");
const parsed = parseYaml(yaml) as Record<string, unknown>;
assertAcyclicLaunchDependencyGraph(file, parsed);
}
});
it("service-backed proof rigs pass canonical rigPreflight with explicit cwdOverride", () => {
const fsOps = {
readFile: (p: string) => readFileSync(p, "utf-8"),