feat(slice-21 FR-5d): rig workspace doctor CLI subcommand + formatters + tests (OPR.0.3.2.21.FR-5)

Fourth commit of the FR-5 chain. Wires the operator-facing CLI for
the daemon route shipped in FR-5c. After this commit `rig workspace
doctor` is end-to-end reachable.

CLI subcommand (packages/cli/src/commands/workspace.ts):
- Added `doctor` subcommand alongside the shipped slice-01 `validate`.
- Options:
  --workspace <path>  override the workspace under check (resolved
                      to absolute via path.resolve before posting)
  --json              structured JSON output for agent pipelines
  --strict            exit non-zero on warn-or-fail (default:
                      non-zero only on fail) per IMPL-PRD §74
- POSTs to /api/workspace/doctor (the FR-5c route) via the existing
  withClient + DaemonClient helper, mirroring the validate request
  shape so wiring is uniform across the workspace surface.
- 4xx/5xx daemon responses print the error JSON to stderr and exit 1
  (same shape as validate).

Human formatter (renderHumanDoctorReport, exported for unit testing):
- Header: `workspace doctor — <workspaceRoot>` + summary counts
- Body: checks grouped under workspace / missions / daemon
  categories per IMPL-PRD §76
  - workspace: workspace_root_reachable, file_allowlist_sane
  - missions: missions_folder_present, optional_slice_docs,
    mission_notes_presence
  - daemon: daemon_points_at_this_workspace, daemon_reload_needed
- Each check rendered as `[OK|WARN|FAIL] <check-name>: <message>`
  with an indented `Fix: <fixHint>` line below failing/warning
  checks
- Forward-compat: any check absent from the documented group map
  (e.g. a future #8 check shipped daemon-side before the CLI knows
  about it) falls into an "other:" section rather than silently
  vanishing

JSON formatter:
- Single-line pass-through of the daemon's DoctorReport (no
  re-shape; the schema is the daemon contract per IMPL-PRD §78)

Exit-code semantics:
- default: exit 1 only on fail
- --strict: exit 1 on warn-or-fail
- HTTP 5xx response: exit 1

Tests (12 in new test file workspace-doctor-cli.test.ts):
- Request-shape (3 tests):
  - default doctor invocation posts to /api/workspace/doctor with
    empty body
  - --workspace passes body.workspaceRoot (absolute)
  - --workspace resolves relative path to absolute before posting
- Output-formatter (4 tests):
  - --json prints single-line structured JSON
  - human output groups checks under the three category labels in
    fixed order (positional asserts on label vs check-name index)
  - fixHint renders under failing/warning checks via the indented
    Fix line
  - forward-compat: ungrouped check falls into "other:" section
- Exit-code (5 tests):
  - all-ok summary → exit 0
  - at least one fail (default mode) → exit 1
  - warns only (default mode) → exit 0
  - warns + --strict → exit 1
  - HTTP 5xx → exit 1

Test pattern mirrors queue.test.ts:
- vi.mock daemon-lifecycle to skip live status probe
- makeDeps factory returns deterministic post responses
- captureStdout helper restores console.log on cleanup
- process.exitCode saved/restored per test

DoctorReport type duplicated in cli/workspace.ts rather than imported
from the daemon package — no @openrig/daemon dependency edge from
the CLI; the HTTP boundary already enforces the JSON shape.

Verification:
- packages/cli tsc EXIT=0
- packages/cli workspace-doctor-cli.test.ts: 12/12 PASS
- Regression run of related CLI tests (workspace-max-files +
  queue + workspace-doctor): 49/49 PASS (8 + 29 + 12)
- Pre-commit staged-diff grep for forbidden-attribution pattern:
  ZERO matches
- Pre-commit FULL-CONTENT grep on every touched file (touch-event-
  scope rule): ZERO matches
- Commit body verification grep clean per banked discipline
- git diff --cached --stat: 2 files (+441 / -6)

Branch: slice-21-fr-5-rig-workspace-doctor off LOCAL main e154a5ce.
Chain: a4b4b67d (FR-5a, CLEAR) → e5d61983 (FR-5b, BLOCKED) →
bc5ec05f (FR-5b fix-up, CLEAR) → 6b21ce3c (FR-5c, BLOCKED on
test determinism) → 784222d3 (FR-5c determinism fix-up, CLEAR) →
this commit (FR-5d CLI subcommand). LOCAL only, NO push.

Files:
- packages/cli/src/commands/workspace.ts (+127 / -6)
- packages/cli/test/workspace-doctor-cli.test.ts (+320 new)
This commit is contained in:
mvschwarz committed 2026-06-01 21:34:06 -07:00
1 parent 784222d30d
commit 941a2e3149
2 files changed
+441 -6

No files matched your search

+121 -6
View File
@@ -1,9 +1,12 @@
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
// PL-007 Workspace Primitive — `rig workspace` CLI surface.
//
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
// parses each .md file's YAML frontmatter, and emits a structured gap
// report. Advisory only — never modifies. curate-steward consumes the
// report.
// Verbs:
// - `rig workspace validate` (slice-01) — walks a workspace root,
// parses each .md file's YAML frontmatter, emits a structured gap
// report. Advisory only — never modifies. curate-steward consumes.
// - `rig workspace doctor` (slice-21 FR-5) — runs the 7-check
// workspace-readiness diagnostic against the daemon's resolved
// workspace (or a --workspace override). Reports state + fix-hints.
import { Command } from "commander";
import { DaemonClient } from "../client.js";
@@ -74,7 +77,7 @@ function emit3PartError(json: boolean, fact: string, consequence: string, action
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.",
"PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 7-check workspace-readiness diagnostic.",
);
const getDeps = (): WorkspaceDeps =>
@@ -142,6 +145,47 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
},
);
// Slice-21 FR-5 — `rig workspace doctor`.
cmd
.command("doctor")
.description(
"Run the 7-check workspace-readiness diagnostic against the daemon's resolved workspace. Reports state of workspace root, missions folder, file allowlist, daemon alignment, daemon reload, slice docs, and MISSION_NOTES. Read-only.",
)
.option(
"--workspace <path>",
"Override the workspace under check (default: daemon-resolved configured root)",
)
.option("--json", "JSON output for agents")
.option(
"--strict",
"Exit non-zero on warn-or-fail (default: non-zero only on fail)",
)
.action(async (opts: { workspace?: string; json?: boolean; strict?: boolean }) => {
const deps = getDeps();
await withClient(deps, async (client) => {
const requestBody: { workspaceRoot?: string } = {};
if (opts.workspace) requestBody.workspaceRoot = path.resolve(opts.workspace);
const res = await client.post<DoctorReport>("/api/workspace/doctor", requestBody);
if (res.status >= 400) {
console.error(JSON.stringify(res.data, null, 2));
process.exitCode = 1;
return;
}
const report = res.data;
if (opts.json) {
console.log(JSON.stringify(report));
} else {
renderHumanDoctorReport(report);
}
// Exit-code semantics per FR-5 IMPL-PRD §74:
// default: non-zero only on fail
// --strict: non-zero on warn-or-fail
const hasFail = report.summary.fail > 0;
const hasWarn = report.summary.warn > 0;
if (hasFail || (opts.strict && hasWarn)) process.exitCode = 1;
});
});
return cmd;
}
@@ -162,3 +206,74 @@ function renderHumanReport(r: ValidationReport): void {
console.log(` ${g.message}`);
}
}
// --- slice-21 FR-5 — `rig workspace doctor` types + human formatter ---
//
// DoctorReport shape mirrors the daemon's runWorkspaceDoctor output
// at packages/daemon/src/domain/workspace/workspace-doctor.ts. Type
// is duplicated here (not imported from the daemon package) because
// the CLI does not have a direct daemon-package dependency edge; the
// HTTP boundary already enforces the JSON shape.
interface DoctorCheckResult {
check: string;
status: "ok" | "warn" | "fail";
message: string;
fixHint?: string;
evidence?: Record<string, unknown>;
}
interface DoctorReport {
workspaceRoot: string;
checks: DoctorCheckResult[];
summary: { ok: number; warn: number; fail: number };
daemonResolvedAt: string;
}
const DOCTOR_CHECK_GROUPS: ReadonlyArray<{ category: string; checks: ReadonlyArray<string> }> = [
{ category: "workspace", checks: ["workspace_root_reachable", "file_allowlist_sane"] },
{ category: "missions", checks: ["missions_folder_present", "optional_slice_docs", "mission_notes_presence"] },
{ category: "daemon", checks: ["daemon_points_at_this_workspace", "daemon_reload_needed"] },
];
function statusIcon(status: DoctorCheckResult["status"]): string {
switch (status) {
case "ok": return "OK";
case "warn": return "WARN";
case "fail": return "FAIL";
}
}
export function renderHumanDoctorReport(report: DoctorReport): void {
console.log(`workspace doctor — ${report.workspaceRoot}`);
console.log(` summary: ${report.summary.ok} ok, ${report.summary.warn} warn, ${report.summary.fail} fail`);
console.log("");
const byName = new Map(report.checks.map((c) => [c.check, c]));
const remaining = new Set(report.checks.map((c) => c.check));
for (const group of DOCTOR_CHECK_GROUPS) {
console.log(`${group.category}:`);
for (const checkName of group.checks) {
const c = byName.get(checkName);
if (!c) continue;
remaining.delete(checkName);
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
}
console.log("");
}
// Any checks not in the documented groups still get rendered so a
// future check addition doesn't silently drop from the human view
// before the group-map is updated.
if (remaining.size > 0) {
console.log("other:");
for (const checkName of remaining) {
const c = byName.get(checkName);
if (!c) continue;
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
}
}
}
@@ -0,0 +1,320 @@
// Slice-21 FR-5 — `rig workspace doctor` CLI subcommand tests.
//
// Covers:
// - default behavior posts to /api/workspace/doctor with empty body
// - --workspace passes body.workspaceRoot
// - --json passes through the structured report
// - human output groups by category (workspace / missions / daemon)
// - exit-code semantics for fail / warn-with-strict / warn-without-strict / all-ok
//
// Mirrors the queue.test.ts pattern: vi.mock daemon-lifecycle to skip
// the live status probe; clientFactory delivers stub HTTP responses.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
workspaceCommand,
renderHumanDoctorReport,
type WorkspaceDeps,
} from "../src/commands/workspace.js";
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
vi.mock("../src/daemon-lifecycle.js", async () => {
const actual = await vi.importActual<Record<string, unknown>>(
"../src/daemon-lifecycle.js",
);
return {
...actual,
getDaemonStatus: vi.fn(async () => ({
state: "running",
healthy: true,
pid: 1234,
port: 7433,
})),
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
};
});
interface DoctorReportFixture {
workspaceRoot: string;
checks: Array<{
check: string;
status: "ok" | "warn" | "fail";
message: string;
fixHint?: string;
}>;
summary: { ok: number; warn: number; fail: number };
daemonResolvedAt: string;
}
function healthyReport(workspaceRoot = "/ws"): DoctorReportFixture {
return {
workspaceRoot,
checks: [
{ check: "workspace_root_reachable", status: "ok", message: `workspace root '${workspaceRoot}' is a reachable directory` },
{ check: "missions_folder_present", status: "ok", message: `missions folder '${workspaceRoot}/missions' is present` },
{ check: "file_allowlist_sane", status: "ok", message: "files.allowlist has 1 usable entry covering workspace root" },
{ check: "daemon_points_at_this_workspace", status: "ok", message: "daemon and caller agree on workspace root" },
{ check: "daemon_reload_needed", status: "ok", message: "config file mtime is older than daemon start" },
{ check: "optional_slice_docs", status: "ok", message: "every slice has a README" },
{ check: "mission_notes_presence", status: "ok", message: "every mission has MISSION_NOTES.md" },
],
summary: { ok: 7, warn: 0, fail: 0 },
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
};
}
function makeDeps(response: { status: number; data: unknown }): {
deps: WorkspaceDeps;
post: ReturnType<typeof vi.fn>;
} {
const post = vi.fn(async (_path: string, _body: unknown) => response);
return {
post,
deps: {
lifecycleDeps: {} as LifecycleDeps,
clientFactory: () => ({
post,
get: vi.fn(),
getText: vi.fn(),
postText: vi.fn(),
}) as unknown as ReturnType<WorkspaceDeps["clientFactory"]>,
},
};
}
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; } };
}
let originalExitCode: number | string | undefined;
beforeEach(() => {
originalExitCode = process.exitCode;
process.exitCode = 0;
});
afterEach(() => {
process.exitCode = originalExitCode;
});
describe("rig workspace doctor — request shape", () => {
it("POSTs to /api/workspace/doctor with empty body when no --workspace", async () => {
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", {});
});
// Discriminator-flip: when --workspace is passed, body.workspaceRoot
// must carry the resolved absolute path. Without path.resolve(), a
// relative input like "./ws" would land as "./ws" on the wire and
// the daemon check #4 would compare against an un-normalized value.
it("POSTs body.workspaceRoot (absolute) when --workspace is supplied", async () => {
const { deps, post } = makeDeps({ status: 200, data: healthyReport("/ws-override") });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync([
"node", "rig", "doctor", "--workspace", "/ws-override",
]);
} finally {
out.restore();
}
expect(post).toHaveBeenCalledWith("/api/workspace/doctor", { workspaceRoot: "/ws-override" });
});
it("resolves relative --workspace to absolute path before posting", async () => {
const { deps, post } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync([
"node", "rig", "doctor", "--workspace", "./local-ws",
]);
} finally {
out.restore();
}
const [, body] = post.mock.calls[0]!;
const sent = body as { workspaceRoot: string };
expect(sent.workspaceRoot.startsWith("/")).toBe(true);
expect(sent.workspaceRoot.endsWith("/local-ws")).toBe(true);
});
});
describe("rig workspace doctor — output formatters", () => {
it("--json prints the report as a single-line JSON document", async () => {
const report = healthyReport();
const { deps } = makeDeps({ status: 200, data: report });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--json"]);
} finally {
out.restore();
}
expect(out.logs).toHaveLength(1);
const parsed = JSON.parse(out.logs[0]!) as DoctorReportFixture;
expect(parsed.summary).toEqual({ ok: 7, warn: 0, fail: 0 });
expect(parsed.checks).toHaveLength(7);
});
it("default human output groups checks under workspace / missions / daemon categories", async () => {
const { deps } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
const joined = out.logs.join("\n");
expect(joined).toContain("workspace doctor — /ws");
expect(joined).toContain("summary: 7 ok, 0 warn, 0 fail");
expect(joined).toContain("workspace:");
expect(joined).toContain("missions:");
expect(joined).toContain("daemon:");
// Discriminator: each check name must appear under the correct
// group section. We assert the category labels appear BEFORE the
// check names that belong to them.
const wsIdx = joined.indexOf("workspace:");
const wsCheckIdx = joined.indexOf("workspace_root_reachable");
const missionsIdx = joined.indexOf("missions:");
const missionsCheckIdx = joined.indexOf("missions_folder_present");
const daemonIdx = joined.indexOf("daemon:");
const daemonCheckIdx = joined.indexOf("daemon_points_at_this_workspace");
expect(wsIdx).toBeLessThan(wsCheckIdx);
expect(missionsIdx).toBeLessThan(missionsCheckIdx);
expect(daemonIdx).toBeLessThan(daemonCheckIdx);
});
// Discriminator-flip: fixHint MUST render below the failing check.
// Without `if (c.fixHint) console.log(...)` operators don't see how
// to fix the failure.
it("human output renders fixHint under failing/warning checks", () => {
const report: DoctorReportFixture = {
workspaceRoot: "/ws",
checks: [
{ check: "workspace_root_reachable", status: "fail", message: "workspace root '/ws' does not exist", fixHint: "run `rig config init-workspace`" },
{ check: "missions_folder_present", status: "ok", message: "ok" },
{ check: "file_allowlist_sane", status: "ok", message: "ok" },
{ check: "daemon_points_at_this_workspace", status: "ok", message: "ok" },
{ check: "daemon_reload_needed", status: "ok", message: "ok" },
{ check: "optional_slice_docs", status: "ok", message: "ok" },
{ check: "mission_notes_presence", status: "ok", message: "ok" },
],
summary: { ok: 6, warn: 0, fail: 1 },
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
};
const out = captureStdout();
try {
renderHumanDoctorReport(report);
} finally {
out.restore();
}
const joined = out.logs.join("\n");
expect(joined).toContain("[FAIL] workspace_root_reachable");
expect(joined).toContain("Fix: run `rig config init-workspace`");
});
// Discriminator-flip: a check absent from DOCTOR_CHECK_GROUPS falls
// into "other:" rather than vanishing. Protects against silent
// future-check drop-off.
it("human output renders ungrouped checks under 'other:'", () => {
const report: DoctorReportFixture = {
workspaceRoot: "/ws",
checks: [
{ check: "future_unknown_check", status: "warn", message: "new check the formatter didn't know about" },
],
summary: { ok: 0, warn: 1, fail: 0 },
daemonResolvedAt: "2026-06-01T12:00:00.000Z",
};
const out = captureStdout();
try {
renderHumanDoctorReport(report);
} finally {
out.restore();
}
const joined = out.logs.join("\n");
expect(joined).toContain("other:");
expect(joined).toContain("future_unknown_check");
});
});
describe("rig workspace doctor — exit-code semantics", () => {
it("exits 0 when summary is all-ok (default mode)", async () => {
const { deps } = makeDeps({ status: 200, data: healthyReport() });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
expect(process.exitCode).toBe(0);
});
// Discriminator-flip: any fail must trip exit code 1 even without
// --strict.
it("exits 1 when summary has at least one fail (default mode)", async () => {
const failing = healthyReport();
failing.checks[0]!.status = "fail";
failing.summary = { ok: 6, warn: 0, fail: 1 };
const { deps } = makeDeps({ status: 200, data: failing });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
expect(process.exitCode).toBe(1);
});
// Discriminator-flip: warns are NOT failures in default mode.
it("exits 0 when summary has warns but no fails (default mode)", async () => {
const warning = healthyReport();
warning.checks[6]!.status = "warn";
warning.summary = { ok: 6, warn: 1, fail: 0 };
const { deps } = makeDeps({ status: 200, data: warning });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
}
expect(process.exitCode).toBe(0);
});
// Discriminator-flip: --strict turns warns into exit-1.
it("exits 1 when summary has warns and --strict is set", async () => {
const warning = healthyReport();
warning.checks[6]!.status = "warn";
warning.summary = { ok: 6, warn: 1, fail: 0 };
const { deps } = makeDeps({ status: 200, data: warning });
const out = captureStdout();
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor", "--strict"]);
} finally {
out.restore();
}
expect(process.exitCode).toBe(1);
});
// Discriminator-flip: HTTP 5xx → exit 1.
it("exits 1 when daemon returns 5xx", async () => {
const { deps } = makeDeps({ status: 503, data: { error: "settings_unavailable" } });
const out = captureStdout();
const stderrOrig = process.stderr.write;
process.stderr.write = ((_chunk: unknown) => true) as typeof process.stderr.write;
try {
await workspaceCommand(deps).parseAsync(["node", "rig", "doctor"]);
} finally {
out.restore();
process.stderr.write = stderrOrig;
}
expect(process.exitCode).toBe(1);
});
});