plumbbob 0.8.18 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +50 -44
  3. package/dist/cli-core.js +35 -15
  4. package/dist/lib/agents.js +230 -112
  5. package/dist/lib/buildlog.js +50 -30
  6. package/dist/lib/buildlogsync.js +21 -12
  7. package/dist/lib/check.js +69 -35
  8. package/dist/lib/commitmsg.js +53 -32
  9. package/dist/lib/git.js +86 -31
  10. package/dist/lib/intent.js +91 -31
  11. package/dist/lib/latch.js +70 -39
  12. package/dist/lib/orient.js +78 -39
  13. package/dist/lib/plugins.js +11 -8
  14. package/dist/lib/settings.js +65 -32
  15. package/dist/lib/sidecar.js +320 -150
  16. package/dist/lib/templates.js +17 -6
  17. package/dist/verbs/agent.js +116 -55
  18. package/dist/verbs/build.js +27 -12
  19. package/dist/verbs/check.js +33 -7
  20. package/dist/verbs/checkpoint.js +153 -78
  21. package/dist/verbs/doctor.js +192 -95
  22. package/dist/verbs/finish.js +86 -45
  23. package/dist/verbs/handoff.js +56 -20
  24. package/dist/verbs/init.js +37 -12
  25. package/dist/verbs/park.js +14 -3
  26. package/dist/verbs/revert.js +64 -24
  27. package/dist/verbs/spike.js +89 -30
  28. package/dist/verbs/start.js +72 -38
  29. package/dist/verbs/status.js +44 -17
  30. package/dist/verbs/turn.js +88 -45
  31. package/dist/verbs/use.js +20 -9
  32. package/package.json +1 -1
  33. package/skills/{pb-build → build}/SKILL.md +25 -25
  34. package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
  35. package/skills/{pb-finish → finish}/SKILL.md +3 -3
  36. package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
  37. package/skills/{pb-park → park}/SKILL.md +2 -2
  38. package/skills/{pb-plan → plan}/SKILL.md +11 -11
  39. package/skills/{pb-refine → refine}/SKILL.md +7 -7
  40. package/skills/{pb-revert → revert}/SKILL.md +2 -2
  41. package/skills/{pb-spike → spike}/SKILL.md +4 -4
  42. package/skills/{pb-status → status}/SKILL.md +3 -3
  43. package/skills/{pb-step → step}/SKILL.md +9 -9
  44. package/skills/{pb-verify → verify}/SKILL.md +8 -8
  45. package/templates/build-log.md +7 -7
  46. package/templates/intent.md +8 -8
  47. package/templates/spike-report.md +1 -1
@@ -1,27 +1,37 @@
1
- // `plumbbob doctor` — two diagnostics under one verb.
1
+ // `plumbbob doctor` — the health report, five sections under one verb.
2
2
  //
3
3
  // 1. The global plugin link (read-only). After `plumbbob init`, plumbbob lives as a
4
4
  // symlink at ~/.claude/skills/plumbbob pointing at the installed package; Claude
5
5
  // Code loads it in place as a plugin (skills `/plumbbob:*`, the post-edit hook from
6
6
  // hooks.json). doctor verifies the link resolves to a package carrying the manifest,
7
7
  // the skills, and the hook — and names the fix for anything missing. The failure
8
- // class it exists for is SILENT (a `/pb-status` that opens an empty dashboard
8
+ // class it exists for is SILENT (a `/plumbbob:status` that opens an empty dashboard
9
9
  // because the plugin never linked).
10
10
  //
11
- // 2. The repo sidecar layout. A repo scaffolded by a pre-restructure plumbbob carries a
12
- // legacy FLAT sidecar (`.plumbbob/intent.md`, `config`, `archive/`) fully git-excluded.
13
- // doctor detects it and, with `--migrate`, moves it into the tracked `builds/<slug>/`
14
- // layout (D31): archive entries and the active session become build folders, `config`
15
- // becomes `settings.json`, and the whole move is STAGED but never committed — the human
16
- // owns that commit (Q8). The move is the one that turns a build's record from local-only
17
- // (dies with `git worktree remove`) into a tracked folder that rides the branch into
18
- // the PR (supersedes D20).
11
+ // 2. The repo sidecar layout. plumbbob keeps its per-repo state in a `.plumbbob/`
12
+ // sidecar: a tracked artifact plane (settings.json plus one `builds/<slug>/` folder
13
+ // per build — intent, build-log, checkpoints, report — that rides its branch into
14
+ // the PR) and a git-excluded control plane of per-worktree files. A repo scaffolded
15
+ // by an older plumbbob carries the legacy FLAT sidecar instead (`.plumbbob/intent.md`,
16
+ // `config`, `archive/`), fully git-excluded, whose record dies with
17
+ // `git worktree remove`. doctor detects it and, with `--migrate`, moves it into the
18
+ // tracked `builds/<slug>/` layout: archive entries and the active session become
19
+ // build folders, `config` becomes `settings.json`, and the whole move is STAGED but
20
+ // never committed — the human owns that commit.
19
21
  //
20
- // 3. The check gate (D32). When a `check` setting overrides checkride, doctor names the
21
- // command; otherwise it runs checkride's own doctor and prints the slot/adapter table —
22
- // "detected but tool missing" is the footgun this exists for.
22
+ // 3. Agent validation. Every resolvable user-authored agent gets its manifest and its
23
+ // `command` statically checked, so a broken agent surfaces here rather than mid-build.
23
24
  //
24
- // Functional, node builtins plus checkride (C1/C2).
25
+ // 4. The check gate. When a `check` setting overrides checkride (the default heavy
26
+ // check), doctor names the command; otherwise it runs checkride's own doctor and
27
+ // prints the slot/adapter table — "detected but tool missing" is the footgun this
28
+ // exists for.
29
+ //
30
+ // 5. The approval latch. Reports whether the harness's turn ledger is live (checkpoint
31
+ // can latch on a recorded human turn) or dormant (guidance only) — dormant is a
32
+ // state, never a failure.
33
+ //
34
+ // Functional, node builtins plus checkride.
25
35
  import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
26
36
  import { homedir } from 'node:os';
27
37
  import { dirname, isAbsolute, join } from 'node:path';
@@ -33,12 +43,18 @@ import { marketplacePlumbbob } from "../lib/plugins.js";
33
43
  import { findRepoRoot, gitPath, stagePath } from "../lib/git.js";
34
44
  import { buildDir, excludeControl, listBuilds, readTurn, setActiveBuild, sidecarDir, slugify } from "../lib/sidecar.js";
35
45
  import { resolveBoolean, resolveString, settingsPath } from "../lib/settings.js";
36
- // The plumbbob package's own skills/ dir (the canonical set), off this module's
37
- // URL so it resolves the same from src/ (dev) and dist/ (published).
46
+ /**
47
+ * Resolve a directory shipped inside the plumbbob package (e.g. its skills/).
48
+ *
49
+ * Resolved off this module's URL so it lands the same from src/ (dev) and
50
+ * dist/ (published).
51
+ */
38
52
  function packageDir(name) {
39
53
  return fileURLToPath(new URL(`../../${name}/`, import.meta.url));
40
54
  }
41
- // Skill directories (those carrying a SKILL.md) under `dir`.
55
+ /**
56
+ * Skill directories (those carrying a SKILL.md) under `dir`.
57
+ */
42
58
  function listSkills(dir) {
43
59
  try {
44
60
  return readdirSync(dir).filter((n) => existsSync(join(dir, n, 'SKILL.md')));
@@ -47,8 +63,10 @@ function listSkills(dir) {
47
63
  return [];
48
64
  }
49
65
  }
50
- // Resolve the dir the plugin link points at: a symlink's target, or the path
51
- // itself if a real directory was copied there. null if nothing is linked.
66
+ /**
67
+ * Resolve the dir the plugin link points at: a symlink's target, or the path
68
+ * itself if a real directory was copied there. null if nothing is linked.
69
+ */
52
70
  function linkedPackage(link) {
53
71
  try {
54
72
  const st = lstatSync(link);
@@ -61,6 +79,12 @@ function linkedPackage(link) {
61
79
  return null;
62
80
  }
63
81
  }
82
+ /**
83
+ * The link-install checks: manifest, skill set, and post-edit hook, each with its fix.
84
+ *
85
+ * `shipped` is the canonical skill list read from the package itself; an installed
86
+ * count below it means a stale or partial link, and every fix is the same re-link.
87
+ */
64
88
  function buildChecks(link, pkg, shipped) {
65
89
  const installed = listSkills(join(pkg, 'skills'));
66
90
  return [
@@ -76,7 +100,15 @@ function buildChecks(link, pkg, shipped) {
76
100
  : { ok: false, label: 'hook missing (hooks/hooks.json)', fix: 're-link: plumbbob init' },
77
101
  ];
78
102
  }
79
- // The plugin-link diagnostic: the checks to print plus how many failed.
103
+ /**
104
+ * The plugin-link diagnostic: how plumbbob is installed into Claude Code, as checks.
105
+ *
106
+ * Two install kinds are recognized: the marketplace plugin (one green line — skills
107
+ * load and the CLI rides the plugin's bin/, nothing to verify piecemeal) and the
108
+ * init-style link at ~/.claude/skills/plumbbob (verified piece by piece). Both at
109
+ * once is a collision — two plugins named plumbbob fight over /plumbbob:* — reported
110
+ * first, with the keep-one fix.
111
+ */
80
112
  function pluginChecks() {
81
113
  const home = process.env.HOME ?? homedir();
82
114
  const link = join(home, '.claude', 'skills', 'plumbbob');
@@ -98,7 +130,9 @@ function pluginChecks() {
98
130
  }
99
131
  return checks;
100
132
  }
101
- // Read a directory's immediate sub-directory names, sorted; [] when it is absent.
133
+ /**
134
+ * Read a directory's immediate sub-directory names, sorted; [] when it is absent.
135
+ */
102
136
  function subdirs(dir) {
103
137
  try {
104
138
  return readdirSync(dir, { withFileTypes: true })
@@ -110,11 +144,15 @@ function subdirs(dir) {
110
144
  return [];
111
145
  }
112
146
  }
113
- // Detect a legacy flat sidecar, or null when the repo is already on the new layout
114
- // (or has no sidecar). `config` and `archive/` are unambiguous pre-restructure markers
115
- // — the new layout has neither. A flat `intent.md` alone would also match today's
116
- // `--local` layout, so it only counts as legacy when the repo is NOT already migrated
117
- // (no `builds/`, no `settings.json`): that guard is what keeps `--local` untouched.
147
+ /**
148
+ * Detect a legacy flat sidecar, or null when the repo is on the current layout
149
+ * (or has no sidecar).
150
+ *
151
+ * `config` and `archive/` are unambiguous legacy markers — the builds/ layout has
152
+ * neither. A flat `intent.md` alone would also match today's `--local` layout, so it
153
+ * only counts as legacy when the repo is NOT already migrated (no `builds/`, no
154
+ * `settings.json`): that guard is what keeps `--local` untouched.
155
+ */
118
156
  export function inspectLegacy(root) {
119
157
  const dir = sidecarDir(root);
120
158
  if (!existsSync(dir))
@@ -127,7 +165,9 @@ export function inspectLegacy(root) {
127
165
  return null;
128
166
  return { config, archive, session };
129
167
  }
130
- // The first `# Heading` in a flat intent.md — the build title the slug derives from.
168
+ /**
169
+ * The first `# Heading` in a flat intent.md — the build title the slug derives from.
170
+ */
131
171
  function titleFromIntent(path) {
132
172
  try {
133
173
  for (const line of readFileSync(path, 'utf8').split('\n')) {
@@ -141,7 +181,9 @@ function titleFromIntent(path) {
141
181
  }
142
182
  return '';
143
183
  }
144
- // The `check=<cmd>` line from a legacy `.plumbbob/config`, or null when absent.
184
+ /**
185
+ * The `check=<cmd>` line from a legacy `.plumbbob/config`, or null when absent.
186
+ */
145
187
  function configCheck(path) {
146
188
  try {
147
189
  for (const line of readFileSync(path, 'utf8').split('\n')) {
@@ -155,9 +197,13 @@ function configCheck(path) {
155
197
  }
156
198
  return null;
157
199
  }
158
- // A slug not already claimed by `taken`, suffixing `-2`, `-3`, … only on collision.
159
- // `start` refuses on collision (D38), but migration is mechanically moving folders that
160
- // already exist, so it disambiguates rather than aborting mid-move.
200
+ /**
201
+ * A slug not already claimed by `taken`, suffixing `-2`, `-3`, … only on collision.
202
+ *
203
+ * `start` refuses a colliding slug outright — the human picks a new title — but
204
+ * migration is mechanically moving folders that already exist, so it disambiguates
205
+ * rather than aborting mid-move.
206
+ */
161
207
  function uniqueSlug(base, taken) {
162
208
  const slug = base.length > 0 ? base : 'migrated-build';
163
209
  if (!taken.has(slug))
@@ -167,7 +213,9 @@ function uniqueSlug(base, taken) {
167
213
  n += 1;
168
214
  return `${slug}-${n}`;
169
215
  }
170
- // Move whichever of `names` exist from `from/` into `to/`.
216
+ /**
217
+ * Move whichever of `names` exist from `from/` into `to/`.
218
+ */
171
219
  function moveInto(from, to, names) {
172
220
  mkdirSync(to, { recursive: true });
173
221
  for (const name of names) {
@@ -176,9 +224,15 @@ function moveInto(from, to, names) {
176
224
  renameSync(src, join(to, name));
177
225
  }
178
226
  }
179
- // Drop the blanket `.plumbbob/` line the legacy layout wrote to info/exclude, then add
180
- // the narrowed control-plane patterns (D17) — so the moved `builds/` and `settings.json`
181
- // become trackable while the per-worktree control files stay excluded.
227
+ /**
228
+ * Replace the legacy blanket `.plumbbob/` exclude with the narrowed control-plane
229
+ * patterns.
230
+ *
231
+ * The flat layout excluded the whole sidecar via the shared gitdir's info/exclude;
232
+ * dropping that line and writing the per-worktree control patterns is what lets the
233
+ * moved `builds/` and `settings.json` become trackable while the control files stay
234
+ * excluded.
235
+ */
182
236
  function narrowExcludes(root) {
183
237
  const exclude = gitPath(root, 'info/exclude');
184
238
  try {
@@ -195,14 +249,17 @@ function narrowExcludes(root) {
195
249
  }
196
250
  excludeControl(root);
197
251
  }
198
- // Perform the migration and return a human-readable list of what moved. STAGES the
199
- // result but never commits (Q8). Returns the actions so doctor can print them; call
200
- // only when `inspectLegacy` reported a legacy layout.
252
+ /**
253
+ * Perform the migration and return a human-readable list of what moved.
254
+ *
255
+ * STAGES the result but never commits — the human owns that commit. Call only when
256
+ * `inspectLegacy` reported a legacy layout; doctor prints the returned actions.
257
+ */
201
258
  export function migrateSidecar(root) {
202
259
  const dir = sidecarDir(root);
203
260
  const actions = [];
204
261
  const taken = new Set(listBuilds(root));
205
- // config → settings.json (only when the new file is not already present).
262
+ // config → settings.json (skipped when settings.json already exists).
206
263
  const configPath = join(dir, 'config');
207
264
  if (existsSync(configPath)) {
208
265
  if (!existsSync(settingsPath(root))) {
@@ -217,8 +274,9 @@ export function migrateSidecar(root) {
217
274
  }
218
275
  rmSync(configPath, { force: true });
219
276
  }
220
- // The flat active session → its own build folder, and the cursor points at it: it is
221
- // the one in-flight build (D28). Migrate it first so it keeps the slug from its title.
277
+ // The flat active session → its own build folder, and the active-build cursor
278
+ // (.plumbbob/STATE) points at it: it is the one in-flight build. Migrate it first
279
+ // so it keeps the slug from its title.
222
280
  const flatIntent = join(dir, 'intent.md');
223
281
  if (existsSync(flatIntent)) {
224
282
  const slug = uniqueSlug(slugify(titleFromIntent(flatIntent)), taken);
@@ -243,9 +301,13 @@ export function migrateSidecar(root) {
243
301
  actions.push('staged the move (builds/ + settings.json) — commit it yourself');
244
302
  return actions;
245
303
  }
246
- // The sidecar section: legacy detection + the offer, or the migration report under
247
- // `--migrate`. Returns the lines to print and how many problems it found (an
248
- // un-migrated legacy layout counts as one, so the exit code flags it).
304
+ /**
305
+ * The sidecar section: legacy detection plus the migrate offer, or the migration
306
+ * report under `--migrate`.
307
+ *
308
+ * Returns the lines to print and how many problems it found (an un-migrated legacy
309
+ * layout counts as one, so the exit code flags it).
310
+ */
249
311
  function sidecarReport(cwd, migrate) {
250
312
  const root = findRepoRoot(cwd);
251
313
  const legacy = root === null ? null : inspectLegacy(root);
@@ -270,16 +332,21 @@ function sidecarReport(cwd, migrate) {
270
332
  lines.push(' (archive/ + the active session → builds/, config → settings.json; staged, never committed)');
271
333
  return { lines, failed: 1 };
272
334
  }
273
- // --- agent validation (D48) ---
274
- // Interpreters recognized as the program of a manifest `command`: when the command
275
- // is `sh <script>` / `node <script>` / …, the checkable artifact is the script
276
- // argument (the interpreter reads it — the file need not carry +x), not the
277
- // interpreter, which we trust to be on PATH.
335
+ // --- agent validation ---
336
+ /**
337
+ * Interpreters recognized as the program of a manifest `command`: when the command
338
+ * is `sh <script>` / `node <script>` / …, the checkable artifact is the script
339
+ * argument (the interpreter reads it — the file need not carry +x), not the
340
+ * interpreter, which we trust to be on PATH.
341
+ */
278
342
  const INTERPRETERS = new Set(['sh', 'bash', 'zsh', 'dash', 'node', 'deno', 'bun', 'python', 'python3', 'ruby', 'perl']);
279
- // Expand PLUMBBOB_AGENT_DIR (D49) in a command and split it into bare tokens
280
- // (surrounding quotes stripped). A deliberately shallow split — enough for the
281
- // static command check below, not a shell parser (which is neither possible nor
282
- // wanted for a best-effort diagnostic).
343
+ /**
344
+ * Expand PLUMBBOB_AGENT_DIR in a command and split it into bare tokens
345
+ * (surrounding quotes stripped).
346
+ *
347
+ * A deliberately shallow split — enough for the static command check below, not a
348
+ * shell parser (which is neither possible nor wanted for a best-effort diagnostic).
349
+ */
283
350
  function commandTokens(command, agentDir) {
284
351
  const expanded = command.replaceAll('${PLUMBBOB_AGENT_DIR}', agentDir).replaceAll('$PLUMBBOB_AGENT_DIR', agentDir);
285
352
  return expanded
@@ -287,14 +354,17 @@ function commandTokens(command, agentDir) {
287
354
  .filter((t) => t.length > 0)
288
355
  .map((t) => t.replace(/^['"]/, '').replace(/['"]$/, ''));
289
356
  }
290
- // Statically check a manifest `command` for the footgun D48 names: a script file
291
- // that does not exist, or a directly-invoked one missing its +x bit. The command
292
- // is a shell string spawned via `sh -c` at the repo root (D49), so a relative path
293
- // resolves against `root` and PLUMBBOB_AGENT_DIR points at the agent's own dir.
294
- // Best-effort by design — a path-shaped program or an interpreter's script
295
- // argument is checked; a bare command (a PATH binary, an inline `node -e` program)
296
- // is trusted, because we cannot prove a shell line broken without running it.
297
- // Returns the problem string, or null when nothing checkable is wrong.
357
+ /**
358
+ * Statically check a manifest `command` for the silent footguns: a script file that
359
+ * does not exist, or a directly-invoked one missing its +x bit.
360
+ *
361
+ * The command is a shell string spawned via `sh -c` with the repo root as cwd, so a
362
+ * relative path resolves against `root` and PLUMBBOB_AGENT_DIR points at the agent's
363
+ * own dir. Best-effort by design — a path-shaped program or an interpreter's script
364
+ * argument is checked; a bare command (a PATH binary, an inline `node -e` program)
365
+ * is trusted, because we cannot prove a shell line broken without running it.
366
+ * Returns the problem string, or null when nothing checkable is wrong.
367
+ */
298
368
  function checkCommand(command, agentDir, root) {
299
369
  const tokens = commandTokens(command, agentDir);
300
370
  if (tokens.length === 0)
@@ -323,21 +393,29 @@ function checkCommand(command, agentDir, root) {
323
393
  }
324
394
  return null;
325
395
  }
326
- // One agent's problem (D48), or null when it validates: a malformed or
327
- // unsupported-contract manifest surfaces as its resolution error; an
328
- // otherwise-valid agent is checked for a missing/non-executable command.
396
+ /**
397
+ * One agent's problem, or null when it validates.
398
+ *
399
+ * A malformed or unsupported-contract manifest surfaces as its resolution error; an
400
+ * otherwise-valid agent is checked for a missing/non-executable command.
401
+ */
329
402
  function agentProblem(listing, root) {
330
403
  if (!listing.resolution.ok)
331
404
  return listing.resolution.error;
332
405
  const { manifest, dir } = listing.resolution.agent;
333
406
  return checkCommand(manifest.command, dir, root);
334
407
  }
335
- // The agent-validation section (D48): walk every resolvable agent across both
336
- // tiers (project + personal) and flag a malformed agent.json, an unsupported
337
- // contract version (both arrive as the listing's parse error), or a
338
- // missing/non-executable command. A repo with no agents shows no section (like the
339
- // sidecar one), keeping doctor quiet for the common case; outside a repo there is
340
- // no project tier to anchor to, so the section is skipped entirely.
408
+ /**
409
+ * The agent-validation section: walk every resolvable user-authored agent across
410
+ * both tiers (project `.plumbbob/agents/` + personal `~/.plumbbob/agents/`) and flag
411
+ * anything broken.
412
+ *
413
+ * Flags a malformed agent.json, an unsupported contract version (both arrive as the
414
+ * listing's parse error), or a missing/non-executable command. A repo with no agents
415
+ * shows no section (like the sidecar one), keeping doctor quiet for the common case;
416
+ * outside a repo there is no project tier to anchor to, so the section is skipped
417
+ * entirely.
418
+ */
341
419
  function agentReport(cwd) {
342
420
  const root = findRepoRoot(cwd);
343
421
  if (root === null)
@@ -360,11 +438,15 @@ function agentReport(cwd) {
360
438
  }
361
439
  return { lines, failed };
362
440
  }
363
- // The check-gate section (D32): a configured `check` setting names the spawn
364
- // override and asks nothing more; otherwise checkride's own doctor reports the
365
- // slot/adapter table. Only rows checkride marks `required` count as problems —
366
- // an empty slot ("no tool detected") is informational, not a failure, because
367
- // the runtime gate already refuses a vacuous run.
441
+ /**
442
+ * The check-gate section: what gates `checkpoint`, and whether it can run.
443
+ *
444
+ * A configured `check` setting names the spawn override and asks nothing more;
445
+ * otherwise checkride's own doctor reports the slot/adapter table. Only rows
446
+ * checkride marks `required` count as problems — an empty slot ("no tool detected")
447
+ * is informational, not a failure, because the runtime gate already refuses a
448
+ * vacuous run.
449
+ */
368
450
  async function gateReport(cwd, env) {
369
451
  const root = findRepoRoot(cwd);
370
452
  if (root === null)
@@ -391,11 +473,10 @@ async function gateReport(cwd, env) {
391
473
  if (c.required && c.status !== 'ok')
392
474
  failed += 1;
393
475
  }
394
- // The week-1 bounce, called out where the human can see it coming
395
- // (research/07 Build 2a): no CODE checks detected means checkpoints are
396
- // gated only by checkride's always-on repo checks — either a vacuous
397
- // refusal or, worse, a green that tested nothing. Informational, with the
398
- // exact fix; `start` surfaces the same probe at plan time.
476
+ // Called out where the human can see it coming: no CODE checks detected means
477
+ // checkpoints would be gated only by checkride's always-on repo checks — either
478
+ // a vacuous refusal or, worse, a green that tested nothing. Informational, with
479
+ // the exact fix; `start` surfaces the same probe at plan time.
399
480
  if (!gateDetectsTools(report.checks)) {
400
481
  lines.push(' ○ gate: no code checks detected — checkpoints would be gated by the always-on repo checks alone;' +
401
482
  ' set {"check": "npm test"} in .plumbbob/settings.json, or add tool configs checkride can see');
@@ -407,8 +488,10 @@ async function gateReport(cwd, env) {
407
488
  return { lines: [...lines, ` ✗ checkride doctor failed — ${message}`], failed: 1 };
408
489
  }
409
490
  }
410
- // One slot line: `✓ types ← tsc (6.0.3)`, `✗ dead ← fallow` with its install
411
- // hint, or `○ spell — no tool detected` for an empty (skipping) slot.
491
+ /**
492
+ * One slot line: `✓ types ← tsc (6.0.3)`, `✗ dead ← fallow` with its install
493
+ * hint, or `○ spell — no tool detected` for an empty (skipping) slot.
494
+ */
412
495
  function toolRow(c) {
413
496
  if (c.adapter === null || c.adapter === undefined) {
414
497
  return ` ○ ${c.slot ?? c.name} — ${c.hint ?? 'no tool detected (slot skips)'}`;
@@ -418,15 +501,20 @@ function toolRow(c) {
418
501
  const hint = c.status !== 'ok' && c.hint !== null ? `\n → ${c.hint}` : '';
419
502
  return ` ${mark} ${c.slot ?? c.name} ← ${c.adapter}${version}${hint}`;
420
503
  }
421
- // The approval-latch health probe (D64): is the turn ledger live? `.plumbbob/TURN`
422
- // holds a count once the UserPromptSubmit hook has ticked at least once, so a present
423
- // count means the checkpoint latch is armed — its absence means the tick is
424
- // guidance-only. Dormant is a legitimate state (a host with no hooks behaves exactly
425
- // as it always has), never a failure — so this section adds nothing to the problem
426
- // count. The dormant hint names the hook because a missing ledger almost always means
427
- // it never wired, and that hook rides in either install kind (the marketplace
428
- // plugin's hooks/hooks.json or an init-style link). Outside a repo there is no
429
- // worktree ledger to read, so the section is skipped entirely.
504
+ /**
505
+ * The approval-latch health probe: is the turn ledger live?
506
+ *
507
+ * The latch is `checkpoint`'s refusal to land a step until the harness has recorded
508
+ * a human turn since the step was entered. `.plumbbob/TURN` holds a count once the
509
+ * UserPromptSubmit hook has ticked at least once, so a present count means the latch
510
+ * is armed — its absence means the tick is guidance-only. Dormant is a legitimate
511
+ * state (a host with no hooks simply has no ledger), never a failure — so this
512
+ * section adds nothing to the problem count. The dormant hint names the hook because
513
+ * a missing ledger almost always means it never wired, and that hook rides in either
514
+ * install kind (the marketplace plugin's hooks/hooks.json or an init-style link).
515
+ * Outside a repo there is no worktree ledger to read, so the section is skipped
516
+ * entirely.
517
+ */
430
518
  function latchReport(cwd) {
431
519
  const root = findRepoRoot(cwd);
432
520
  if (root === null)
@@ -436,14 +524,23 @@ function latchReport(cwd) {
436
524
  ? ' ○ latch: dormant — guidance only (no turn ledger yet; it ticks on your first prompt when the UserPromptSubmit hook is wired — re-run after one to confirm)'
437
525
  : ` ✓ latch: live (turn ${turn})`;
438
526
  const lines = ['', 'plumbbob doctor — approval latch (D64)', line];
439
- // D67: a settings `auto` used to be a standing grant; the latch no longer honors it
440
- // (a model can write that file). Surface a set value so a human who relied on it isn't
527
+ // The latch ignores a settings `auto`: a model can write that file, and a grant
528
+ // the model can forge is no grant — self-approval comes only from the human's
529
+ // typed `/build --auto`. Surface a set value so a human relying on it isn't
441
530
  // silently changed — informational, never a problem.
442
531
  if (resolveBoolean(root, 'auto', false)) {
443
- lines.push(' ○ auto: set in settings but not a grant since D67 — self-approve per run with `/pb-build --auto`');
532
+ lines.push(' ○ auto: set in settings but not a grant since D67 — self-approve per run with `/plumbbob:build --auto`');
444
533
  }
445
534
  return { lines, failed: 0 };
446
535
  }
536
+ /**
537
+ * Run every doctor section, print the report, and return the exit code.
538
+ *
539
+ * Sections in order: plugin install, sidecar layout (migrating under `--migrate`),
540
+ * agents, check gate, approval latch. Exit 1 when any section reported a problem, so
541
+ * scripts can gate on it; the closing lines cover the restart and the PATH story
542
+ * either way. `env` injects a deterministic toolchain probe for tests (see GateEnv).
543
+ */
447
544
  export async function doctor(cwd, args = [], env) {
448
545
  const checks = pluginChecks();
449
546
  const out = ['plumbbob doctor — plugin install'];
@@ -463,7 +560,7 @@ export async function doctor(cwd, args = [], env) {
463
560
  out.push(failed === 0
464
561
  ? 'plumbbob: all checks passed. If a skill still misbehaves, restart Claude Code (or /reload-plugins).'
465
562
  : `plumbbob: ${failed} problem(s) — apply the → fixes, then restart Claude Code.`);
466
- out.push('plumbbob: skills shell a bare `plumbbob`. The marketplace plugin puts it on PATH only inside a Claude Code session (via bin/) — there is no terminal `plumbbob`; for one (or the skills-dir install) run `npm i -g plumbbob`. In-session you can also run this as `/pb-doctor`. Sessions are per-project via `plumbbob start`.');
563
+ out.push('plumbbob: skills shell a bare `plumbbob`. The marketplace plugin puts it on PATH only inside a Claude Code session (via bin/) — there is no terminal `plumbbob`; for one (or the skills-dir install) run `npm i -g plumbbob`. In-session you can also run this as `/plumbbob:doctor`. Sessions are per-project via `plumbbob start`.');
467
564
  process.stdout.write(`${out.join('\n')}\n`);
468
565
  return failed === 0 ? 0 : 1;
469
566
  }