merge: OPR.0.3.3.04.2 guided golden path state clarity

This commit is contained in:
mvschwarz committed 2026-06-08 21:21:47 -07:00
commit c491827bd7
11 files changed
+325 -15

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
# Getting started: the guided golden path
This is the ordered new-operator path from a fresh machine to a running rig with
a workspace and a workflow. It uses the **existing** `rig` verbs in order - there
is no magic one-command onboarding; each step is a real, inspectable verb.
> Everything below reports **what is currently true**, never a guarantee that
> downstream work will succeed. "Daemon up" does not mean every agent is healthy;
> "kernel ready" does not mean every kernel agent is healthy; a workspace root
> being *live* does not mean it is the *right* one for your project.
## The ordered path
1. **`rig setup`** - install and verify the local runtime (tmux, cmux, Claude
Code / Codex, runtime config). Run once per machine. `rig setup` does NOT boot
the kernel (see "Kernel framing" below).
2. **`rig up <rig-spec>`** - launch a rig. This **auto-starts the daemon** if it
is not already running (the four daemon-dependent verbs below do not auto-start
it - they tell you to run `rig up` or `rig daemon start`).
3. **`rig status`** - see what is currently true: daemon up/down + port, kernel
readiness (distinct from daemon health), and your effective workspace root
(default vs override).
4. **`rig workspace doctor`** - check that your workspace is ready; it reports
state + fix-hints. The effective workspace root defaults to
`~/.openrig/workspace/` unless you override `workspace.root` (env or config).
5. **`rig workflow instantiate <name>`** - start a workflow runtime instance by
its **discovered name** (e.g. `conveyor`, `basic-loop`) - no hidden file path
needed. `rig workflow specs` shows the seeded built-ins. Instantiating creates a
workflow instance and its first **entry qitem**.
6. **`rig scope ...`** - browse and manage the durable mission/slice artifacts the
workflow coordinates (see the scope <-> workflow bridge below).
`rig setup` prints this ordered sequence as its next-steps output; `rig status`
points back here. This doc is the durable reference.
## Kernel framing (what `rig setup` does and does not do)
The kernel rig **auto-boots on daemon-start** - it is daemon-start behavior, NOT
a `rig setup` step and NOT something you boot by hand. So:
- `rig setup` installs/verifies the runtime; it does not start the daemon or the
kernel.
- Starting the daemon (`rig daemon start`, or implicitly via `rig up`) is what
boots the kernel rig in the background.
- **Kernel readiness is a distinct signal from daemon health.** The daemon's HTTP
health binds early; the kernel can still be booting or a kernel agent can be
unhealthy while the daemon is up. `rig status` surfaces kernel readiness
separately (via `/api/kernel/status`); `rig daemon start --wait-for-kernel`
polls it.
## The scope <-> workflow bridge
Two related primitives, often confused by new operators:
- **`rig scope`** manages **durable, on-disk artifacts** - missions and slices
(markdown/YAML files in your workspace). These are the persistent record of
*what work exists*.
- **`rig workflow`** manages a **runtime instance** - when you
`rig workflow instantiate <name>`, the daemon creates a workflow instance plus
an **entry qitem** that routes the first step to an owner. This is the live
coordination of *who does the next step*.
How they relate: a scope slice is the durable description of a unit of work; a
workflow instance + its qitems are the live machinery that moves that work
through owners (hot-potato handoffs). They are not auto-bridged - there is no
auto-instantiate-from-scope - you instantiate a workflow by name when you want to
run one, and you reference your scope artifacts as the work it coordinates. A
typical loop: author/track the unit in `rig scope`, then
`rig workflow instantiate <name>` to start the runtime that drives it.
+2 -2
View File
@@ -1,7 +1,7 @@
import nodePath from "node:path";
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -30,7 +30,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running");
printDaemonNotRunning();
return null;
}
return deps.clientFactory(getDaemonUrl(status));
+2 -2
View File
@@ -1,6 +1,6 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -11,7 +11,7 @@ export function discoverCommand(depsOverride?: StatusDeps): Command {
async function getClient(deps: StatusDeps): Promise<DaemonClient | null> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running");
printDaemonNotRunning();
return null;
}
return deps.clientFactory(getDaemonUrl(status));
+27 -1
View File
@@ -599,6 +599,23 @@ function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
};
}
/**
* OPR.0.3.3.04.2 (AC-1): the ONE canonical ordered golden path over EXISTING
* verbs - no magic mega-command, no hidden state. `rig setup` prints this as its
* next-steps; `rig status`/`rig doctor` only HINT back to it; the durable
* reference is docs/reference/getting-started.md. Returns the lines to print.
*/
export function goldenPathNextSteps(): string[] {
return [
"Next steps (the guided path; full reference: docs/reference/getting-started.md):",
" 1. rig up <rig-spec> Launch a rig (auto-starts the daemon; the kernel boots on daemon-start)",
" 2. rig status See daemon port, kernel readiness, and your effective workspace root",
" 3. rig workspace doctor Check your workspace is ready",
" 4. rig workflow instantiate <name> Start a workflow by its discovered name (e.g. conveyor); 'rig workflow specs' shows built-ins",
" 5. rig scope ... Browse the durable mission/slice artifacts the workflow coordinates",
];
}
export function setupCommand(depsOverride?: SetupDeps): Command {
const cmd = new Command("setup").description("Prepare the machine for OpenRig");
@@ -632,7 +649,16 @@ export function setupCommand(depsOverride?: SetupDeps): Command {
if (step.fixHint) console.log(` Fix: ${step.fixHint}`);
}
console.log(`\n${result.ready ? "Setup complete." : "Some steps need attention. Run `rig doctor` for detailed diagnostics."}`);
// OPR.0.3.3.04.2 (AC-1): the canonical ordered golden path. `rig setup` is
// the primary surface for the new-operator sequence (status/doctor only
// HINT back to it; the durable reference is docs/reference/getting-started.md).
if (result.ready) {
console.log("\nSetup complete.\n");
for (const line of goldenPathNextSteps()) console.log(line);
} else {
console.log("\nSome steps need attention. Run `rig doctor` for detailed diagnostics.");
console.log("Once setup is healthy, follow the guided path: docs/reference/getting-started.md");
}
if (!opts.dryRun && !result.ready) process.exitCode = 1;
});
+35 -2
View File
@@ -8,6 +8,7 @@ import {
type LifecycleDeps,
} from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import { ConfigStore } from "../config-store.js";
export interface StatusDeps {
lifecycleDeps: LifecycleDeps;
@@ -52,14 +53,39 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
const client = deps.clientFactory(getDaemonUrl(status));
// Fetch summary + cmux status
const [summaryRes, cmuxRes] = await Promise.all([
// Fetch summary + cmux + kernel readiness
const [summaryRes, cmuxRes, kernelRes] = await Promise.all([
client.get<Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null }>>("/api/rigs/summary"),
client.get<{ available: boolean }>("/api/adapters/cmux/status").catch(() => null),
client.get<{ kernel_state?: string; error?: string }>("/api/kernel/status").catch(() => null),
]);
console.log(`Daemon running on port ${status.port}`);
// OPR.0.3.3.04.2 (AC-2): kernel readiness is a DISTINCT signal from daemon
// health - the kernel rig auto-boots on daemon-start, and a daemon can be up
// while the kernel is not yet ready (or a kernel agent is unhealthy). Surface
// it as "here's what's currently true," never as a guarantee that downstream
// agents are healthy.
if (kernelRes && kernelRes.status === 200 && kernelRes.data?.kernel_state) {
console.log(`Kernel: ${kernelRes.data.kernel_state} (boots on daemon-start; distinct from daemon health)`);
} else if (kernelRes && kernelRes.status === 503) {
console.log("Kernel: not tracked (no kernel-boot tracker wired)");
} else {
console.log("Kernel: unknown (status unavailable)");
}
// OPR.0.3.3.04.2 (AC-2 / gap #7): surface WHICH workspace root is effective
// and whether it is the default or an override - the operator never guesses.
// This reports what is currently live, NOT that any root is the right one.
try {
const resolved = new ConfigStore().resolveWithSource("workspace.root");
const origin = resolved.source === "default" ? "default" : `override via ${resolved.source}`;
console.log(`Workspace root: ${resolved.value} (${origin})`);
} catch {
// config resolution unavailable - omit rather than guess.
}
if (summaryRes.status !== 200) {
console.error(`Failed to fetch rig summary (HTTP ${summaryRes.status})`);
process.exitCode = 1;
@@ -80,6 +106,13 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
// cmux status
const cmuxAvailable = cmuxRes?.data?.available ?? false;
console.log(`cmux: ${cmuxAvailable ? "available" : "unavailable"}`);
// OPR.0.3.3.04.2 (AC-1): reinforcing HINT back to the one canonical ordered
// path - status does not re-author the sequence (that lives in `rig setup`
// next-steps + docs/reference/getting-started.md).
if (rigs.length === 0) {
console.log("\nNext: launch a rig with `rig up <rig-spec>`. Guided path: `rig setup` output or docs/reference/getting-started.md");
}
});
return cmd;
+2 -3
View File
@@ -1,6 +1,6 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -24,8 +24,7 @@ async function withClient<T>(
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
printDaemonNotRunning();
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
+2 -3
View File
@@ -10,7 +10,7 @@
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
import * as path from "node:path";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
@@ -41,8 +41,7 @@ async function withClient<T>(
): Promise<T | undefined> {
const status = await getDaemonStatus(deps.lifecycleDeps);
if (status.state !== "running" || status.healthy === false) {
console.error("Daemon not running. Start it with: rig daemon start");
process.exitCode = 1;
printDaemonNotRunning();
return undefined;
}
const client = deps.clientFactory(getDaemonUrl(status));
+40
View File
@@ -26,6 +26,46 @@ export function getDaemonUrl(status: DaemonStatus): string {
return `http://${status.host ?? DEFAULT_HOST}:${status.port}`;
}
/**
* OPR.0.3.3.04.2 (AC-4): the ONE shared honest daemon-not-running error for the
* daemon-dependent journey verbs (bootstrap / discover / workspace / workflow).
* Mirrors the repo's 3-part fact / consequence / action convention
* (commands/archive.ts, commands/queue.ts) so the daemon-dependency UX is
* consistent across the new-operator journey instead of four divergent bare
* dead-ends. Bounded to the daemon-not-running path - NOT a general
* error-framework (that would be its own slice).
*/
export interface DaemonNotRunningError {
fact: string;
consequence: string;
action: string;
}
export function daemonNotRunningError(): DaemonNotRunningError {
return {
fact: "Daemon not running.",
consequence: "This command needs a running daemon.",
action: "Run 'rig up' (it auto-starts the daemon), or 'rig daemon start'.",
};
}
/**
* Print the shared daemon-not-running error and set a failing exit code. Pass
* `{ json: true }` for the agent-facing `{ error: { fact, consequence, action } }`
* envelope (matching commands/queue.ts JSON output).
*/
export function printDaemonNotRunning(opts?: { json?: boolean }): void {
const err = daemonNotRunningError();
if (opts?.json) {
console.log(JSON.stringify({ error: err }));
} else {
console.error(`Error: ${err.fact}`);
console.error(` ${err.consequence}`);
console.error(` ${err.action}`);
}
process.exitCode = 1;
}
export interface StartOptions {
port?: number;
host?: string;
@@ -11,6 +11,8 @@ import {
resolveDaemonPath,
buildDaemonEnv,
ensureWorkspaceScaffold,
daemonNotRunningError,
printDaemonNotRunning,
STATE_FILE,
LOG_FILE,
OPENRIG_DIR,
@@ -1197,3 +1199,53 @@ describe("ensureWorkspaceScaffold", () => {
);
});
});
// OPR.0.3.3.04.2 (AC-4): the ONE shared daemon-not-running honest error used by
// the daemon-dependent journey verbs (bootstrap/discover/workspace/workflow).
describe("daemonNotRunningError / printDaemonNotRunning (AC-4 shared honest error)", () => {
it("daemonNotRunningError is the 3-part fact/consequence/action shape pointing to rig up / rig daemon start", () => {
const err = daemonNotRunningError();
expect(err.fact).toContain("Daemon not running");
expect(err.consequence).toContain("needs a running daemon");
expect(err.action).toContain("rig up");
expect(err.action).toContain("rig daemon start");
});
it("printDaemonNotRunning (human) emits all three parts on stderr and sets exit code 1", () => {
const lines: string[] = [];
const origErr = console.error;
const origExit = process.exitCode;
console.error = (...a: unknown[]) => { lines.push(a.join(" ")); };
process.exitCode = undefined;
try {
printDaemonNotRunning();
} finally {
console.error = origErr;
}
const exit = process.exitCode;
process.exitCode = origExit;
const out = lines.join("\n");
expect(out).toContain("Daemon not running");
expect(out).toContain("needs a running daemon");
expect(out).toContain("rig up");
expect(exit).toBe(1);
});
it("printDaemonNotRunning({ json: true }) emits the { error: { fact, consequence, action } } envelope", () => {
const lines: string[] = [];
const origLog = console.log;
const origExit = process.exitCode;
console.log = (...a: unknown[]) => { lines.push(a.join(" ")); };
process.exitCode = undefined;
try {
printDaemonNotRunning({ json: true });
} finally {
console.log = origLog;
}
process.exitCode = origExit;
const parsed = JSON.parse(lines.join("")) as { error: { fact: string; consequence: string; action: string } };
expect(parsed.error.fact).toContain("Daemon not running");
expect(parsed.error.consequence).toContain("needs a running daemon");
expect(parsed.error.action).toContain("rig up");
});
});
+25 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { Command } from "commander";
import { setupCommand, runSetup, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
import { setupCommand, runSetup, goldenPathNextSteps, type SetupDeps, type SetupResult } from "../src/commands/setup.js";
import type { DoctorDeps } from "../src/commands/doctor.js";
import { resolveCmuxSettingsPath } from "../src/cmux-config.js";
@@ -207,6 +207,30 @@ describe("rig setup", () => {
expect(verify?.status).toBe("pass");
});
// OPR.0.3.3.04.2 (AC-1): the ONE canonical ordered golden path over EXISTING
// verbs (no mega-command), in sequence, with the durable doc reference.
it("AC-1: goldenPathNextSteps is the ordered sequence over existing verbs, with the durable doc", () => {
const out = goldenPathNextSteps().join("\n");
const upIdx = out.indexOf("rig up");
const statusIdx = out.indexOf("rig status");
const wsIdx = out.indexOf("rig workspace doctor");
const wfIdx = out.indexOf("rig workflow instantiate");
const scopeIdx = out.indexOf("rig scope");
expect(upIdx).toBeGreaterThan(-1);
expect(statusIdx).toBeGreaterThan(upIdx);
expect(wsIdx).toBeGreaterThan(statusIdx);
expect(wfIdx).toBeGreaterThan(wsIdx);
expect(scopeIdx).toBeGreaterThan(wfIdx);
expect(out).toContain("docs/reference/getting-started.md");
// no magic mega-command - the path is existing verbs only
expect(out).not.toMatch(/rig (journey|onboarding)\b/);
// built-in discovery surface is `rig workflow specs` (lists registered specs,
// built-ins tagged "(built-in)"), NOT `rig workflow list` (lists instances) -
// the path must point a fresh operator at the right discovery command.
expect(out).toContain("rig workflow specs");
expect(out).not.toContain("rig workflow list");
});
it("fails setup honestly when tmux is installed but the default control socket is unhealthy", async () => {
const deps = makeDeps({
exec: (cmd: string) => {
+69 -1
View File
@@ -43,7 +43,11 @@ function captureLogs(fn: () => Promise<void>): Promise<string[]> {
}
// Echo server for daemon API
function createDaemonServer(summaryData: unknown[], cmuxStatus: unknown) {
function createDaemonServer(
summaryData: unknown[],
cmuxStatus: unknown,
kernelStatus?: { code: number; body: unknown },
) {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, "http://localhost");
@@ -57,6 +61,12 @@ function createDaemonServer(summaryData: unknown[], cmuxStatus: unknown) {
res.end(JSON.stringify(cmuxStatus));
return;
}
if (url.pathname === "/api/kernel/status") {
const k = kernelStatus ?? { code: 200, body: { kernel_state: "ready" } };
res.writeHead(k.code, { "Content-Type": "application/json" });
res.end(JSON.stringify(k.body));
return;
}
if (url.pathname === "/healthz") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
@@ -130,6 +140,64 @@ describe("rig status", () => {
});
});
// OPR.0.3.3.04.2 (AC-2): state clarity - daemon port, kernel readiness
// (distinct from daemon health), effective workspace root (default vs override).
describe("AC-2 state clarity (OPR.0.3.3.04.2)", () => {
let srv: ReturnType<typeof createDaemonServer>;
let port: number;
beforeAll(async () => {
srv = createDaemonServer([], { available: false }, { code: 200, body: { kernel_state: "ready" } });
port = await srv.listen();
});
afterAll(async () => { await srv.close(); });
function statusDeps(): StatusDeps {
return {
lifecycleDeps: mockLifecycleDeps({
exists: vi.fn((p: string) => p === STATE_FILE),
readFile: vi.fn((p: string) => (p === STATE_FILE ? JSON.stringify(runningState(port)) : null)),
fetch: vi.fn(async () => ({ ok: true })),
}),
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
};
}
it("surfaces daemon port + kernel readiness (distinct from daemon health) + effective workspace root with its source", async () => {
const prev = process.env.OPENRIG_WORKSPACE_ROOT;
delete process.env.OPENRIG_WORKSPACE_ROOT; // not env-overridden in this case
try {
const program = new Command();
program.addCommand(statusCommand(statusDeps()));
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "status"]));
const output = logs.join("\n");
expect(output).toMatch(/Daemon running on port \d+/);
expect(output).toContain("Kernel: ready");
expect(output).toContain("distinct from daemon health");
// the effective root is surfaced WITH whether it's default vs an override
// (host-independent: a host may legitimately carry a file-level override).
expect(output).toMatch(/Workspace root: .+ \((default|override via (env|file))\)/);
} finally {
if (prev === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT;
else process.env.OPENRIG_WORKSPACE_ROOT = prev;
}
});
it("marks the workspace root as an override when OPENRIG_WORKSPACE_ROOT is set", async () => {
const prev = process.env.OPENRIG_WORKSPACE_ROOT;
process.env.OPENRIG_WORKSPACE_ROOT = process.cwd(); // a real existing dir
try {
const program = new Command();
program.addCommand(statusCommand(statusDeps()));
const logs = await captureLogs(() => program.parseAsync(["node", "rig", "status"]));
const output = logs.join("\n");
expect(output).toContain(`Workspace root: ${process.cwd()} (override via env)`);
} finally {
if (prev === undefined) delete process.env.OPENRIG_WORKSPACE_ROOT;
else process.env.OPENRIG_WORKSPACE_ROOT = prev;
}
});
});
// Test 2: Running daemon with no rigs -> 'No rigs'
describe("with running daemon and no rigs", () => {
let srv: ReturnType<typeof createDaemonServer>;