Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/a-doctor-says-what-would-stop-a-run.md
Original file line number Diff line number Diff line change
@@ -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 <id>` adds the preflight's own answer for one change. Exit `0`
nothing would stop a run, `1` something would, `2` it could not look.
1 change: 1 addition & 0 deletions HARNESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` for one change |

The harness sequences CLI-agent runs (or a mechanical action) across the
stages of one OpenSpec change: `propose → review → apply → verify →
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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
Expand Down
131 changes: 110 additions & 21 deletions openspec/changes/a-doctor-says-what-would-stop-a-run/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<EnvironmentReport>`.
- [ ] 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` / `<Y` / `^X` clause forms this repository pins, on the major
version, and answers `undefined` for anything else — reported as
`worth-knowing` ("this check cannot read that range") rather than
guessed. A partial comparator that treated what it could not parse as
satisfied is how a check stops checking.
- [x] 1.3 The `openspec` CLI: resolved on the PATH or not. Absent is
`stops-a-run` — `validate-change` and the `archive` stage both call
it.
- [ ] 1.4 Each agent in `AGENT_REGISTRY`: present or absent, from
- [x] 1.4 Each agent in `AGENT_REGISTRY`: present or absent, from
`detectAvailableAgentsDetailed` in
`packages/core/src/agent-detection.ts`, including the version it
reports where it has one. Do not probe a second way: the picker, the
REST route and the VS Code bridge all read that function, and a
command answering "is this agent here" differently from the picker in
the same build is the drift this change objects to elsewhere.
- [ ] 1.5 The workspace's harness configuration: `resolveHarnessConfig`
`openspec` and `npm` are not agents and are not in that map, so
`detectCliAgent` is exported from the same module as
`detectExecutable` and used for them — the same probe, not a second
one written beside it.
- [x] 1.5 The workspace's harness configuration: `resolveHarnessConfig`
on the global file reads, or the error it raised, as `stops-a-run`.
- [ ] 1.6 The workspace lease, via `readWorkspaceLeaseHolder`: who holds
- [x] 1.6 The workspace lease, via `readWorkspaceLeaseHolder`: who holds
it, or that it is free. Held is `worth-knowing`, never `stops-a-run`
— a busy workspace is not a broken one.
- [ ] 1.7 The git identity, via `readGitAuthor`: present or not. Absent
- [x] 1.7 The git identity, via `readGitAuthor`: present or not. Absent
is `worth-knowing`, with the remedy naming `git config user.email`. A
lease taken without one is valid.
- [ ] 1.8 No environment variable's value is read or reported. A finding
- [x] 1.8 No environment variable's value is read or reported. A finding
may name a variable; it may not print what it contains.
- [ ] 1.9 `packages/core/src/environment-report.test.ts`: a report over
Nothing in this module reads `process.env` at all; the only runtime
fact it takes is `process.version`.
- [x] 1.9 `packages/core/src/environment-report.test.ts`: a report over
a fixture with everything present has no `stops-a-run` finding; a
missing `openspec` produces one; an out-of-range Node version produces
one quoting the range; a held lease produces a `worth-knowing`
finding; every executable in `AGENT_REGISTRY` appears in the report —
the assertion that fails when an agent is added and this is not
updated.
Ten tests, all passing, including two on `satisfiesMajorRange` — one
of them asserting that a range it cannot read is `undefined` rather
than "satisfied".

## 2. The command

- [ ] 2.1 `openspec-ui-cli doctor [--cwd <path>] [--change <id>]
- [x] 2.1 `openspec-ui-cli doctor [--cwd <path>] [--change <id>]
[--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 <id>` additionally calls `resolveChainStart` with the
- [x] 2.2 `--change <id>` 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.
```
131 changes: 131 additions & 0 deletions packages/cli/src/doctor-command.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] });
});
});
Loading
Loading