diff --git a/.changeset/a-doctor-says-what-would-stop-a-run.md b/.changeset/a-doctor-says-what-would-stop-a-run.md new file mode 100644 index 00000000..5fe95366 --- /dev/null +++ b/.changeset/a-doctor-says-what-would-stop-a-run.md @@ -0,0 +1,12 @@ +--- +"@openspec-ui/core": minor +"@openspec-ui/cli": minor +--- + +`openspec-ui-cli doctor` reports what this machine and this workspace are +missing before a run is started, instead of leaving it to be discovered +by being refused: the runtime against the pinned engines, the `openspec` +CLI, which agents are installed, whether the harness configuration reads, +who holds the workspace, and whether a git identity is configured. +`--change ` adds the preflight's own answer for one change. Exit `0` +nothing would stop a run, `1` something would, `2` it could not look. diff --git a/HARNESS.md b/HARNESS.md index 410bf06f..fb239c3b 100644 --- a/HARNESS.md +++ b/HARNESS.md @@ -18,6 +18,7 @@ what does **not** cap it), see [`LIMITS.md`](LIMITS.md). | Compare agents, models, effort, and caps | [Agent reference](#agents-models-effort-and-spending-caps) | | Hand one numbered task to an agent | [`taskAgents`](#taskagents) | | Set a spending ceiling | [Harness Spending Limits](LIMITS.md) | +| Find out what would stop a run here | `openspec-ui-cli doctor`, and `doctor --change ` for one change | The harness sequences CLI-agent runs (or a mechanical action) across the stages of one OpenSpec change: `propose → review → apply → verify → diff --git a/README.md b/README.md index d461512d..3d8ff2cf 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,24 @@ npm run start --workspace @openspec-ui/cli -- validate --cwd . --format text `openspec-validate` job) runs it against `openspec/changes/` on every push/PR, as the real merge gate. +### `doctor` — what would stop a run here + +`openspec-ui-cli doctor` answers, before a run is started, what this +machine and this workspace are missing: the runtime against the pinned +`engines`, the `openspec` CLI, which agents are installed, whether the +harness configuration reads, who holds the workspace, and whether a git +identity is configured. `--change ` adds the preflight's own answer +for one change, from the same resolution a run would use. + +```bash +npm run start --workspace @openspec-ui/cli -- doctor --cwd . +``` + +Exit codes: `0` nothing found would stop a run, `1` something would, `2` +it could not look. A workspace held by a live run is reported and exits +`0` — being busy is not being broken, which is the answer `lease` +already gives. + ## Getting Started 1. Read `docs/adr/0001-*.md` — the architecture decisions and rejected diff --git a/openspec/changes/a-doctor-says-what-would-stop-a-run/tasks.md b/openspec/changes/a-doctor-says-what-would-stop-a-run/tasks.md index 3764ddde..e832136a 100644 --- a/openspec/changes/a-doctor-says-what-would-stop-a-run/tasks.md +++ b/openspec/changes/a-doctor-says-what-would-stop-a-run/tasks.md @@ -3,81 +3,170 @@ only be asked by starting a run and being refused. ## 1. The report -- [ ] 1.1 `packages/core/src/environment-report.ts` exports +- [x] 1.1 `packages/core/src/environment-report.ts` exports `Finding` (`{ id, severity, statement, remedy? }`, `severity` one of `"stops-a-run" | "worth-knowing"`) and `readEnvironmentReport({ workspaceRoot }): Promise`. -- [ ] 1.2 The runtime: the running Node.js and npm versions against the + Plus `stopsARun(report)`, so the one question the exit code is derived + from is answered in core rather than by each caller filtering + severities its own way. +- [x] 1.2 The runtime: the running Node.js and npm versions against the root `package.json`'s `engines`. Outside the range is `stops-a-run`, with the pinned range quoted in the statement. -- [ ] 1.3 The `openspec` CLI: resolved on the PATH or not. Absent is + Range reading is `satisfiesMajorRange`, deliberately tiny: it reads the + `>=X` / `] [--change ] +- [x] 2.1 `openspec-ui-cli doctor [--cwd ] [--change ] [--format text|json]` in `packages/cli/src/doctor-command.ts`, wired in `packages/cli/src/main.ts` with its own `USAGE` entry and its exit codes documented there. -- [ ] 2.2 `--change ` additionally calls `resolveChainStart` with the +- [x] 2.2 `--change ` additionally calls `resolveChainStart` with the same resolver `runChange` builds, and prints its refusal's `reason` and `configKey`. Do not re-derive any precondition it answers. -- [ ] 2.3 Exit `0` where no finding stops a run, `1` where one does, `2` + `canAnswerCheckpoints` comes from the same `deps.checkpoint.ask` the + run derives it from, passed in by `main.ts` rather than read off + `process.stdin` a second time. +- [x] 2.3 Exit `0` where no finding stops a run, `1` where one does, `2` where the report could not be produced. A held workspace alone exits `0`. -- [ ] 2.4 `--format json` prints the `EnvironmentReport` shape unchanged +- [x] 2.4 `--format json` prints the `EnvironmentReport` shape unchanged from core. -- [ ] 2.5 `packages/cli/src/doctor-command.test.ts`: a clean report exits +- [x] 2.5 `packages/cli/src/doctor-command.test.ts`: a clean report exits 0; a `stops-a-run` finding exits 1 and names the remedy; a held workspace alone exits 0; `--change` on a change whose configuration refuses prints the preflight's own reason and `configKey` and exits 1; an unreadable workspace exits 2. + Seven tests, all passing — the five above plus a change that would + start (exit 0) and the json shape. ## 3. Documentation -- [ ] 3.1 `README.md`'s "CI CLI (merge gate)" section documents `doctor` +- [x] 3.1 `README.md`'s "CI CLI (merge gate)" section documents `doctor` and its three exit codes. -- [ ] 3.2 `HARNESS.md`'s task index gains a row: "Find out what would +- [x] 3.2 `HARNESS.md`'s task index gains a row: "Find out what would stop a run here" → `openspec-ui-cli doctor`. ## 4. Verification -- [ ] 4.1 This change validates strictly. `check(validate-change)` -- [ ] 4.2 `npm run verify` unpiped, after the last edit, with everything +- [x] 4.1 This change validates strictly. `check(validate-change)` + `openspec validate --strict --changes` — 6 passed, 0 failed, this + change among them. +- [x] 4.2 `npm run verify` unpiped, after the last edit, with everything staged. Record the run and the per-package test counts. -- [ ] 4.3 A changeset exists: `core` and `cli` minor. + 2026-09-12, exit 0 on the third attempt — the first two are the point + of running it after the last edit rather than before. The first failed + `typecheck`: the CLI test's stand-in for a successful + `resolveChainStart` returned `config: {}`, which is not a + `HarnessConfig`. The second failed `lint:test-budgets`: a test file + that writes into a temporary directory must state a time budget, so + the file now carries `vi.setConfig({ testTimeout: 15_000 })` with the + measurement (38ms for the whole file) beside it. + Tests on the passing run: cli 114 across 11 files, core 1102 across + 78, vscode 327 across 24, server 83 across 4, webui 389 across 42 — + 2015 across 159 files, 0 failed. +- [x] 4.3 A changeset exists: `core` and `cli` minor. `check(changeset-present)` -- [ ] 4.4 **Delegated to `claude-cli`**: run `openspec-ui-cli doctor` on + `.changeset/a-doctor-says-what-would-stop-a-run.md`. +- [x] 4.4 **Delegated to `claude-cli`**: run `openspec-ui-cli doctor` on this repository and quote the output and exit code; then run it with `PATH` stripped of `openspec` and quote the output and exit code. Evidence: both outputs verbatim, showing the same command reporting a healthy machine and a broken one. -- [ ] 4.5 **Delegated to `claude-cli`**: while a run holds the + 2026-09-12, on this repository, exit 0: + + ``` + Nothing here would stop a run. + Worth knowing: + Not installed here: codex-cli, gemini-cli, local-llm, gemini-cli-acp, + codex-cli-acp. A stage configured to use one of them will refuse to + start. + ``` + + The same command with `PATH` cut back to Node and the system + directories, exit 1 — which also exposed a different Node and npm than + the pinned ones, so three findings rather than the one that was being + provoked: + + ``` + 4 things would stop a run: + Running node is v24.18.0; this workspace pins >=22 <23. + Use the runtime pinned in package.json (volta + engines), not an + arbitrary global one. + The `openspec` CLI is not on this PATH. + Install it: npm install -g @openspec/cli + Running npm is 12.0.1; this workspace pins >=10 <11. + Use the runtime pinned in package.json (volta + engines), not an + arbitrary global one. + No agent this build carries is installed, so no stage that needs one + can run. + Install at least one of: claude-cli, copilot-cli, codex-cli, + gemini-cli, local-llm, copilot-cli-acp, gemini-cli-acp, + codex-cli-acp, claude-cli-acp + Worth knowing: + No git identity is configured here, so a lease taken from this + directory records none. + git config user.email you@example.com + ``` + + And `doctor --change two-steps-to-a-run` on this repository, exit 1, + quoting the preflight's own refusal rather than a second opinion: + `"two-steps-to-a-run" would not start here: this change's + autonomyLevel is "assisted", ...` / `the setting that governs this is + autonomyLevel`. +- [x] 4.5 **Delegated to `claude-cli`**: while a run holds the workspace, run `openspec-ui-cli doctor` and quote the lease finding and the exit code, which must be `0`. Evidence: the output and the exit code. + 2026-09-12. A real `openspec-ui-cli run` held a scratch workspace + while this was asked of it. Exit **0**, with the holder under "Worth + knowing": + + ``` + terminal run on HPP-NTB63, pid 10948, git author + verycomplexandlongname@gmail.com holds this workspace (last reported + itself 4s ago). + Wait for it, or stop that process. `openspec-ui-cli lease` + describes it. + ``` diff --git a/packages/cli/src/doctor-command.test.ts b/packages/cli/src/doctor-command.test.ts new file mode 100644 index 00000000..8e8ec69a --- /dev/null +++ b/packages/cli/src/doctor-command.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { doctorCommand } from "./doctor-command.js"; + +// The exit code is the whole contract: 0 nothing would stop a run, 1 +// something would, 2 could not look. The case that matters most is the +// held workspace, which must exit 0 — `lease` already reports being held +// without calling it a failure, and two commands disagreeing about +// whether busy means broken is worse than either answer. +// See a-doctor-says-what-would-stop-a-run. + +function collect() { + const out: string[] = []; + const err: string[] = []; + return { out, err, deps: { stdout: (line: string) => out.push(line), stderr: (line: string) => err.push(line) } }; +} + +const CLEAN = { workspaceRoot: "/repo", findings: [] }; + +describe("doctorCommand", () => { + it("exits 0 and says so when nothing would stop a run", async () => { + const { out, deps } = collect(); + const code = await doctorCommand( + { workspaceRoot: "/repo", canAnswerCheckpoints: true, format: "text" }, + { ...deps, read: async () => CLEAN }, + ); + expect(code).toBe(0); + expect(out.join("\n")).toContain("Nothing here would stop a run."); + }); + + it("exits 1 and names the remedy when something would", async () => { + const { out, deps } = collect(); + const code = await doctorCommand( + { workspaceRoot: "/repo", canAnswerCheckpoints: true, format: "text" }, + { + ...deps, + read: async () => ({ + workspaceRoot: "/repo", + findings: [{ + id: "openspec-cli", + severity: "stops-a-run" as const, + statement: "The `openspec` CLI is not on this PATH.", + remedy: "Install it: npm install -g @openspec/cli", + }], + }), + }, + ); + expect(code).toBe(1); + expect(out.join("\n")).toContain("npm install -g"); + }); + + it("exits 0 for a workspace held by a live run", async () => { + const { out, deps } = collect(); + const code = await doctorCommand( + { workspaceRoot: "/repo", canAnswerCheckpoints: true, format: "text" }, + { + ...deps, + read: async () => ({ + workspaceRoot: "/repo", + findings: [{ + id: "workspace-held", + severity: "worth-knowing" as const, + statement: "terminal run on somewhere, pid 4242 holds this workspace.", + }], + }), + }, + ); + expect(code).toBe(0); + expect(out.join("\n")).toContain("Worth knowing:"); + expect(out.join("\n")).toContain("pid 4242"); + }); + + it("exits 2 when the report could not be produced", async () => { + const { err, deps } = collect(); + const code = await doctorCommand( + { workspaceRoot: "/repo", canAnswerCheckpoints: true, format: "text" }, + { ...deps, read: async () => { throw new Error("no such directory"); } }, + ); + expect(code).toBe(2); + expect(err.join("\n")).toContain("no such directory"); + }); + + it("reports the preflight's own reason and setting for a named change, and exits 1", async () => { + const { out, deps } = collect(); + const code = await doctorCommand( + { workspaceRoot: "/repo", changeName: "a-change", canAnswerCheckpoints: false, format: "text" }, + { + ...deps, + read: async () => CLEAN, + resolveStart: async () => ({ + ok: false as const, + refusal: { + reason: "this change's configuration pauses between stages for a confirmation", + configKey: "checkpoints.requireConfirmationBetweenSteps", + }, + }), + }, + ); + expect(code).toBe(1); + const text = out.join("\n"); + expect(text).toContain("would not start here"); + expect(text).toContain("checkpoints.requireConfirmationBetweenSteps"); + }); + + it("exits 0 for a named change that would start, on a machine with nothing wrong", async () => { + const { out, deps } = collect(); + const code = await doctorCommand( + { workspaceRoot: "/repo", changeName: "a-change", canAnswerCheckpoints: true, format: "text" }, + { + ...deps, + read: async () => CLEAN, + resolveStart: async () => ({ + ok: true as const, + config: { stepAgents: {}, autonomyLevel: "autonomous" as const, reviewGate: { mode: "human-required" as const } }, + changeDir: "/repo/openspec/changes/a-change", + }), + }, + ); + expect(code).toBe(0); + expect(out.join("\n")).toContain('"a-change" would start here.'); + }); + + it("prints the report's own shape as json, and keeps the same exit code", async () => { + const { out, deps } = collect(); + const code = await doctorCommand( + { workspaceRoot: "/repo", canAnswerCheckpoints: true, format: "json" }, + { ...deps, read: async () => CLEAN }, + ); + expect(code).toBe(0); + expect(JSON.parse(out.join("\n"))).toEqual({ workspaceRoot: "/repo", findings: [] }); + }); +}); diff --git a/packages/cli/src/doctor-command.ts b/packages/cli/src/doctor-command.ts new file mode 100644 index 00000000..3f691415 --- /dev/null +++ b/packages/cli/src/doctor-command.ts @@ -0,0 +1,136 @@ +// `openspec-ui-cli doctor` — what would stop a run here, asked before +// starting one (a-doctor-says-what-would-stop-a-run). +// +// Presentation only. What this machine and this workspace have is read +// by core (`readEnvironmentReport`), and whether one named change may +// start is answered by the same `resolveChainStart` a run would use; +// this turns either into something a person reads and an exit code. + +import { + buildDefaultAgentRunners, + readEnvironmentReport, + resolveChainStart, + resolveRunner, + stopsARun, + type EnvironmentReport, +} from "@openspec-ui/core"; + +export interface DoctorOptions { + workspaceRoot: string; + /** Ask about one change as well: the preflight's own answer for it. */ + changeName?: string; + /** Whether a confirmation could be put to anybody from here — the + * same fact `run` derives its refusal from, passed in rather than read + * off `process.stdin` a second time. */ + canAnswerCheckpoints: boolean; + format: "text" | "json"; +} + +export interface DoctorDeps { + stdout: (line: string) => void; + stderr: (line: string) => void; + /** Test seams, the same shape `leaseCommand` already offers. */ + read?: typeof readEnvironmentReport; + resolveStart?: typeof resolveChainStart; +} + +interface ChangeAnswer { + changeName: string; + canStart: boolean; + reason?: string; + configKey?: string; +} + +/** `0` nothing found would stop a run, `1` something would, `2` the + * report could not be produced. + * + * A workspace held by a live run exits `0`: it is a fact, not a fault, + * and `lease` already reports being held without calling it a failure. */ +export async function doctorCommand(options: DoctorOptions, deps: DoctorDeps): Promise { + let report: EnvironmentReport; + try { + report = await (deps.read ?? readEnvironmentReport)({ workspaceRoot: options.workspaceRoot }); + } catch (error) { + deps.stderr(`openspec-ui-cli: could not look at this workspace: ${message(error)}`); + return 2; + } + + let change: ChangeAnswer | undefined; + if (options.changeName !== undefined) { + try { + change = await askAboutChange(options, deps); + } catch (error) { + deps.stderr(`openspec-ui-cli: could not resolve "${options.changeName}": ${message(error)}`); + return 2; + } + } + + const blocked = stopsARun(report) || change?.canStart === false; + + if (options.format === "json") { + deps.stdout(JSON.stringify({ ...report, ...(change ? { change } : {}) }, null, 2)); + return blocked ? 1 : 0; + } + + for (const line of describe(report, change)) deps.stdout(line); + return blocked ? 1 : 0; +} + +/** The preflight's own answer, from the same resolver a run is given. + * Not a second implementation of "may this change start": two of those + * drift, and the drift shows up as a doctor that says yes to a run that + * is then refused. */ +async function askAboutChange(options: DoctorOptions, deps: DoctorDeps): Promise { + const changeName = options.changeName as string; + const runners = buildDefaultAgentRunners({ workspaceRoot: options.workspaceRoot }); + const start = await (deps.resolveStart ?? resolveChainStart)({ + workspaceRoot: options.workspaceRoot, + changeName, + canAnswerCheckpoints: options.canAnswerCheckpoints, + resolveRunner: (agentId) => resolveRunner(runners, agentId), + }); + if (start.ok) return { changeName, canStart: true }; + return { + changeName, + canStart: false, + reason: start.refusal.reason, + ...(start.refusal.configKey !== undefined ? { configKey: start.refusal.configKey } : {}), + }; +} + +function describe(report: EnvironmentReport, change: ChangeAnswer | undefined): string[] { + const lines: string[] = []; + const stopping = report.findings.filter((finding) => finding.severity === "stops-a-run"); + const worthKnowing = report.findings.filter((finding) => finding.severity === "worth-knowing"); + + if (stopping.length === 0) { + lines.push("Nothing here would stop a run."); + } else { + lines.push(stopping.length === 1 ? "One thing would stop a run:" : `${stopping.length} things would stop a run:`); + for (const finding of stopping) { + lines.push(` ${finding.statement}`); + if (finding.remedy) lines.push(` ${finding.remedy}`); + } + } + + if (worthKnowing.length > 0) { + lines.push("Worth knowing:"); + for (const finding of worthKnowing) { + lines.push(` ${finding.statement}`); + if (finding.remedy) lines.push(` ${finding.remedy}`); + } + } + + if (change) { + lines.push(change.canStart + ? `"${change.changeName}" would start here.` + : `"${change.changeName}" would not start here: ${change.reason}`); + if (change.configKey) lines.push(` the setting that governs this is ${change.configKey}`); + } + + return lines; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index df660988..c4fc6b4e 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -9,6 +9,7 @@ import { renderChangeAncestry, renderChangeTree } from "./change-graph-render.js import { checkChange } from "./check-change.js"; import { leaseCommand } from "./lease-command.js"; import { runChange, type CheckpointPrompt } from "./run-change.js"; +import { doctorCommand } from "./doctor-command.js"; import { readyCommand } from "./ready-command.js"; import { worktreeCommand } from "./worktree-command.js"; import { runValidateAll, type ValidateAllResult } from "./openspec-validate.js"; @@ -26,6 +27,7 @@ Usage: openspec-ui-cli run [--cwd ] [--format text|json] openspec-ui-cli check [--cwd ] [--format text|json] openspec-ui-cli ready [--cwd ] [--base ] [--format text|json] + openspec-ui-cli doctor [--cwd ] [--change ] [--format text|json] openspec-ui-cli lease [--cwd ] [--format text|json] openspec-ui-cli lease release [--cwd ] [--format text|json] openspec-ui-cli worktree add [--cwd ] [--path ] @@ -82,6 +84,12 @@ process is not running. A live holder is refused: taking its lease would let a second mutating run start against files it still has open, which is what the lease exists to prevent. +'doctor' exits 0 when nothing it found would stop a run, 1 when +something would, and 2 when it could not look. A workspace held by a +live run is reported and exits 0: being busy is not being broken. +'--change ' adds the preflight's own answer for that change, from +the same resolution a run would use. + A run does only what the change's own harness configuration already permits. There is no flag that starts a chain for a change configured to run one stage at a time, and none that answers a confirmation the change @@ -122,6 +130,7 @@ export interface MainDeps { runChange?: typeof runChange; worktreeCommand?: typeof worktreeCommand; readyCommand?: typeof readyCommand; + doctorCommand?: typeof doctorCommand; checkChange?: typeof checkChange; leaseCommand?: typeof leaseCommand; /** How a checkpoint is put to a person, and how their answer comes @@ -256,6 +265,20 @@ export async function runMain(argv: string[], deps: MainDeps = {}): Promise { + return detectCliAgent(executable); +} + function detectCliAgent(executable: string): Promise { return new Promise((resolve) => { let settled = false; diff --git a/packages/core/src/environment-report.test.ts b/packages/core/src/environment-report.test.ts new file mode 100644 index 00000000..334d996f --- /dev/null +++ b/packages/core/src/environment-report.test.ts @@ -0,0 +1,153 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AGENT_REGISTRY } from "./agents/registry.js"; +import { readEnvironmentReport, satisfiesMajorRange, stopsARun } from "./environment-report.js"; + +// The report must not become a second opinion about anything: agent +// presence, the lease holder and the git identity are all asked of the +// readers that already decide them, and the tests below drive it through +// those seams. What is genuinely this module's is the classification — +// what stops a run and what is only worth knowing — and every test here +// is about that. See a-doctor-says-what-would-stop-a-run. + +// every-varying-check-has-a-budget: each test writes one small manifest +// into a temporary directory and reads it back, and every probe is a +// test seam, so nothing here spawns a process. Measured 2026-09-12 at +// 38ms for the whole file; the ceiling is for a loaded machine, not for +// this work. +vi.setConfig({ testTimeout: 15_000 }); + +let workspaceRoot: string; + +const ALL_AGENTS_PRESENT = Object.fromEntries(AGENT_REGISTRY.map((agent) => [agent.id, { detected: true }])); + +async function healthyWorkspace(engines: Record = { node: ">=22 <23" }): Promise { + workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "environment-report-")); + await writeFile(path.join(workspaceRoot, "package.json"), JSON.stringify({ name: "fixture", engines }), "utf8"); + return workspaceRoot; +} + +function deps(overrides: Parameters[0] extends infer T ? Partial : never = {}) { + return { + workspaceRoot, + detectAgents: async () => ALL_AGENTS_PRESENT, + detectTool: async () => ({ detected: true, version: "10.9.0" }), + readLeaseHolder: async () => undefined, + readAuthor: async () => "somebody@example.com", + nodeVersion: "v22.11.0", + ...overrides, + }; +} + +afterEach(async () => { + if (workspaceRoot) await rm(workspaceRoot, { recursive: true, force: true }); +}); + +describe("satisfiesMajorRange", () => { + it("reads the clause forms this repository pins", () => { + expect(satisfiesMajorRange("v22.11.0", ">=22 <23")).toBe(true); + expect(satisfiesMajorRange("v23.0.0", ">=22 <23")).toBe(false); + expect(satisfiesMajorRange("10.9.0", ">=10 <11")).toBe(true); + }); + + it("answers undefined for a range it cannot read, rather than treating it as satisfied", () => { + // A partial implementation that guessed "satisfied" here is how a + // check stops checking without anybody noticing. + expect(satisfiesMajorRange("v22.11.0", "22.x || 23.x")).toBeUndefined(); + expect(satisfiesMajorRange("not-a-version", ">=22")).toBeUndefined(); + }); +}); + +describe("readEnvironmentReport", () => { + it("finds nothing on a workspace where everything is present", async () => { + await healthyWorkspace(); + const report = await readEnvironmentReport(deps()); + expect(report.findings).toEqual([]); + expect(stopsARun(report)).toBe(false); + }); + + it("reports a runtime outside the pinned range as stopping a run, quoting the range", async () => { + await healthyWorkspace({ node: ">=22 <23" }); + const report = await readEnvironmentReport(deps({ nodeVersion: "v20.9.0" })); + const finding = report.findings.find((entry) => entry.id === "runtime-node"); + expect(finding?.severity).toBe("stops-a-run"); + expect(finding?.statement).toContain(">=22 <23"); + expect(finding?.statement).toContain("v20.9.0"); + expect(stopsARun(report)).toBe(true); + }); + + it("reports a missing openspec CLI as stopping a run, with the command that installs it", async () => { + await healthyWorkspace(); + const report = await readEnvironmentReport(deps({ + detectTool: async (executable: string) => ({ detected: executable !== "openspec" }), + })); + const finding = report.findings.find((entry) => entry.id === "openspec-cli"); + expect(finding?.severity).toBe("stops-a-run"); + expect(finding?.remedy).toContain("npm install -g"); + }); + + it("covers every agent in the registry, so an agent added later is not silently unreported", async () => { + await healthyWorkspace(); + const [first, ...rest] = AGENT_REGISTRY; + const report = await readEnvironmentReport(deps({ + detectAgents: async () => ({ + ...Object.fromEntries(rest.map((agent) => [agent.id, { detected: true }])), + [first!.id]: { detected: false }, + }), + })); + const finding = report.findings.find((entry) => entry.id === "some-agents-missing"); + expect(finding?.severity).toBe("worth-knowing"); + expect(finding?.statement).toContain(first!.id); + }); + + it("reports no agent at all as stopping a run", async () => { + await healthyWorkspace(); + const report = await readEnvironmentReport(deps({ + detectAgents: async () => Object.fromEntries(AGENT_REGISTRY.map((agent) => [agent.id, { detected: false }])), + })); + expect(report.findings.find((entry) => entry.id === "no-agent")?.severity).toBe("stops-a-run"); + expect(stopsARun(report)).toBe(true); + }); + + it("reports a held workspace as worth knowing, never as stopping a run", async () => { + await healthyWorkspace(); + const report = await readEnvironmentReport(deps({ + readLeaseHolder: async () => ({ + hostKind: "cli" as const, + hostname: "somewhere", + pid: 4242, + heartbeatAgeMs: 3000, + author: "somebody@example.com", + }), + })); + const finding = report.findings.find((entry) => entry.id === "workspace-held"); + // A busy workspace is not a broken one, and `lease` exits 0 on one. + expect(finding?.severity).toBe("worth-knowing"); + expect(finding?.statement).toContain("pid 4242"); + expect(finding?.statement).toContain("somebody@example.com"); + expect(stopsARun(report)).toBe(false); + }); + + it("reports an unreadable harness configuration as stopping a run", async () => { + await healthyWorkspace(); + await writeFile(path.join(workspaceRoot, "openspec", "agent-harness.json"), "{ not json", "utf8") + .catch(async () => { + const { mkdir } = await import("node:fs/promises"); + await mkdir(path.join(workspaceRoot, "openspec"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "openspec", "agent-harness.json"), "{ not json", "utf8"); + }); + const report = await readEnvironmentReport(deps()); + expect(report.findings.find((entry) => entry.id === "harness-config")?.severity).toBe("stops-a-run"); + }); + + it("reports a missing git identity as worth knowing, with the command that sets one", async () => { + await healthyWorkspace(); + const report = await readEnvironmentReport(deps({ readAuthor: async () => undefined })); + const finding = report.findings.find((entry) => entry.id === "git-identity"); + expect(finding?.severity).toBe("worth-knowing"); + expect(finding?.remedy).toContain("git config user.email"); + expect(stopsARun(report)).toBe(false); + }); +}); diff --git a/packages/core/src/environment-report.ts b/packages/core/src/environment-report.ts new file mode 100644 index 00000000..8da7d785 --- /dev/null +++ b/packages/core/src/environment-report.ts @@ -0,0 +1,231 @@ +// What this machine and this workspace are missing, asked before a run +// rather than discovered by being refused — a-doctor-says-what-would-stop-a-run. +// +// `resolveChainStart` already answers every precondition of one change's +// run, and answers it only when somebody names a change and asks to run +// it. A person setting this up on a new machine finds out what is +// missing by being told no, which is the shape `a-lease-says-who` +// rejected for the workspace lease: a strange way to ask a question, and +// one that answers it only at the moment you are being refused. +// +// Every fact here is read from something that already decides it: +// agent presence from `detectAvailableAgentsDetailed`, the holder of the +// workspace from `readWorkspaceLeaseHolder`, the git identity from +// `readGitAuthor`, the configuration from `resolveHarnessConfig`. This +// module aggregates and classifies; it decides nothing a second time. + +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { detectAvailableAgentsDetailed, detectExecutable, type DetectedAgent } from "./agent-detection.js"; +import { AGENT_REGISTRY } from "./agents/registry.js"; +import { readGitAuthor } from "./git.js"; +import { resolveHarnessConfig } from "./harness-config.js"; +import { hostKindLabel, readWorkspaceLeaseHolder, type WorkspaceLeaseConflict } from "./workspace-lease.js"; + +/** How much a finding matters. + * + * Two values, not a scale: the question this report answers is "will a + * run start here", and a finding either stops one or does not. A middle + * value would be a place to put anything nobody wanted to decide + * about. */ +export type FindingSeverity = "stops-a-run" | "worth-knowing"; + +export interface Finding { + /** Stable across runs, so a script can act on one without matching + * prose. */ + id: string; + severity: FindingSeverity; + /** What is true, in one sentence a person reads. */ + statement: string; + /** What to do about it — a command this repository has, where there is + * one. Absent where the remedy is not a command (a workspace held by + * somebody else's live run is waited for, not fixed). */ + remedy?: string; +} + +export interface EnvironmentReport { + workspaceRoot: string; + findings: Finding[]; +} + +export interface EnvironmentReportOptions { + workspaceRoot: string; + /** Test seams. Production passes nothing and gets the real probes — + * the same shape every other reader in this package offers. */ + detectAgents?: typeof detectAvailableAgentsDetailed; + detectTool?: typeof detectExecutable; + readLeaseHolder?: typeof readWorkspaceLeaseHolder; + readAuthor?: typeof readGitAuthor; + /** The running Node.js version, `process.version` in production. */ + nodeVersion?: string; +} + +/** True when `version` satisfies `range`, `false` when it does not, and + * `undefined` when this cannot tell. + * + * Deliberately tiny: it reads the `>=X` / ` clause.length > 0); + if (clauses.length === 0) return undefined; + + let satisfied = true; + for (const clause of clauses) { + const match = /^(>=|<=|<|>|\^|=)?v?(\d+)/u.exec(clause); + if (!match) return undefined; + const bound = Number(match[2]); + switch (match[1]) { + case ">=": satisfied &&= major >= bound; break; + case ">": satisfied &&= major > bound; break; + case "<=": satisfied &&= major <= bound; break; + case "<": satisfied &&= major < bound; break; + case "^": satisfied &&= major === bound; break; + case "=": case undefined: satisfied &&= major === bound; break; + default: return undefined; + } + } + return satisfied; +} + +async function readEngines(workspaceRoot: string): Promise> { + try { + const manifest = JSON.parse(await readFile(path.join(workspaceRoot, "package.json"), "utf8")) as { + engines?: Record; + }; + return manifest.engines ?? {}; + } catch { + // A workspace with no manifest pins no runtime, which is a fact + // about the workspace and not a finding about the machine. + return {}; + } +} + +function runtimeFinding(tool: string, version: string | undefined, range: string | undefined): Finding | undefined { + if (range === undefined) return undefined; + if (version === undefined) { + return { + id: `runtime-${tool}-unknown`, + severity: "worth-knowing", + statement: `This workspace pins ${tool} ${range}, and the running ${tool} version could not be read.`, + }; + } + const satisfied = satisfiesMajorRange(version, range); + if (satisfied === true) return undefined; + if (satisfied === undefined) { + return { + id: `runtime-${tool}-unreadable-range`, + severity: "worth-knowing", + statement: `This workspace pins ${tool} "${range}", which this check cannot read. Running ${tool} is ${version}.`, + }; + } + return { + id: `runtime-${tool}`, + severity: "stops-a-run", + statement: `Running ${tool} is ${version}; this workspace pins ${range}.`, + remedy: "Use the runtime pinned in package.json (volta + engines), not an arbitrary global one.", + }; +} + +function leaseFinding(holder: WorkspaceLeaseConflict | undefined): Finding | undefined { + if (!holder) return undefined; + const author = holder.author ? `, git author ${holder.author}` : ""; + // Held, never `stops-a-run`. A busy workspace is not a broken one, and + // reporting it as a failure would make this disagree with `lease`, + // which exits 0 either way. + return { + id: "workspace-held", + severity: "worth-knowing", + statement: + `${hostKindLabel(holder.hostKind)} on ${holder.hostname}, pid ${holder.pid}${author}` + + ` holds this workspace (last reported itself ${Math.round(holder.heartbeatAgeMs / 1000)}s ago).`, + remedy: "Wait for it, or stop that process. `openspec-ui-cli lease` describes it.", + }; +} + +export async function readEnvironmentReport(options: EnvironmentReportOptions): Promise { + const workspaceRoot = path.resolve(options.workspaceRoot); + const detectAgents = options.detectAgents ?? detectAvailableAgentsDetailed; + const detectTool = options.detectTool ?? detectExecutable; + const findings: Finding[] = []; + + const engines = await readEngines(workspaceRoot); + const nodeFinding = runtimeFinding("node", options.nodeVersion ?? process.version, engines["node"]); + if (nodeFinding) findings.push(nodeFinding); + + const [openspec, npm, agents, holder, author] = await Promise.all([ + detectTool("openspec"), + engines["npm"] === undefined ? Promise.resolve({ detected: true }) : detectTool("npm"), + detectAgents(), + (options.readLeaseHolder ?? readWorkspaceLeaseHolder)(workspaceRoot), + (options.readAuthor ?? readGitAuthor)(workspaceRoot), + ]); + + if (!openspec.detected) { + findings.push({ + id: "openspec-cli", + severity: "stops-a-run", + statement: "The `openspec` CLI is not on this PATH.", + remedy: "Install it: npm install -g @openspec/cli", + }); + } + + const npmFinding = runtimeFinding("npm", npm.detected ? npm.version : undefined, engines["npm"]); + if (npmFinding) findings.push(npmFinding); + + // Every agent this build carries, from the registry rather than a + // second list — an agent added later is covered without anybody + // remembering this file. + const missing = AGENT_REGISTRY.filter((agent) => agents[agent.id]?.detected !== true).map((agent) => agent.id); + if (missing.length === AGENT_REGISTRY.length) { + findings.push({ + id: "no-agent", + severity: "stops-a-run", + statement: "No agent this build carries is installed, so no stage that needs one can run.", + remedy: "Install at least one of: " + AGENT_REGISTRY.map((agent) => agent.id).join(", "), + }); + } else if (missing.length > 0) { + findings.push({ + id: "some-agents-missing", + severity: "worth-knowing", + statement: `Not installed here: ${missing.join(", ")}. A stage configured to use one of them will refuse to start.`, + }); + } + + try { + await resolveHarnessConfig(workspaceRoot); + } catch (error) { + findings.push({ + id: "harness-config", + severity: "stops-a-run", + statement: `This workspace's harness configuration could not be read: ${error instanceof Error ? error.message : String(error)}`, + remedy: "Fix openspec/agent-harness.json. HARNESS.md documents every key it accepts.", + }); + } + + const held = leaseFinding(holder); + if (held) findings.push(held); + + if (author === undefined) { + findings.push({ + id: "git-identity", + severity: "worth-knowing", + statement: "No git identity is configured here, so a lease taken from this directory records none.", + remedy: "git config user.email you@example.com", + }); + } + + return { workspaceRoot, findings }; +} + +/** Whether anything found would stop a run — the one question the exit + * code is derived from. */ +export function stopsARun(report: EnvironmentReport): boolean { + return report.findings.some((finding) => finding.severity === "stops-a-run"); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61770b7b..25103b37 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,6 +30,7 @@ export * from "./checkpoint.js"; export * from "./workbench-run-journal.js"; export * from "./workbench-recovery.js"; export * from "./agent-detection.js"; +export * from "./environment-report.js"; export * from "./changeset-reminder.js"; export * from "./change-dates.js"; export * from "./verify-quality.js";