mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
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 tipf3449baf(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:
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}
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user