plumbbob 0.9.5 → 0.10.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 (144) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +89 -87
  3. package/dist/cli-core.js +18 -8
  4. package/dist/cli.js +1 -1
  5. package/dist/lib/agents.js +26 -26
  6. package/dist/lib/buildlog.js +21 -11
  7. package/dist/lib/buildlogsync.js +8 -8
  8. package/dist/lib/check.js +14 -14
  9. package/dist/lib/commitbody.js +72 -0
  10. package/dist/lib/commitmsg.js +7 -7
  11. package/dist/lib/git.js +44 -15
  12. package/dist/lib/intent.js +20 -19
  13. package/dist/lib/latch.js +16 -16
  14. package/dist/lib/orient.js +36 -17
  15. package/dist/lib/reentry.js +3 -3
  16. package/dist/lib/settings.js +16 -16
  17. package/dist/lib/sidecar.js +70 -54
  18. package/dist/lib/templates.js +1 -1
  19. package/dist/verbs/abandon.js +122 -0
  20. package/dist/verbs/agent.js +25 -25
  21. package/dist/verbs/build.js +9 -9
  22. package/dist/verbs/check.js +6 -6
  23. package/dist/verbs/checkpoint.js +48 -47
  24. package/dist/verbs/doctor.js +35 -29
  25. package/dist/verbs/finish.js +27 -40
  26. package/dist/verbs/handoff.js +9 -8
  27. package/dist/verbs/init.js +9 -9
  28. package/dist/verbs/park.js +3 -3
  29. package/dist/verbs/recover.js +12 -12
  30. package/dist/verbs/revert.js +12 -12
  31. package/dist/verbs/spike.js +21 -21
  32. package/dist/verbs/start.js +49 -19
  33. package/dist/verbs/status.js +8 -8
  34. package/dist/verbs/turn.js +16 -16
  35. package/dist/verbs/use.js +4 -4
  36. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  37. package/node_modules/checkride/CHANGELOG.md +401 -0
  38. package/node_modules/checkride/README.md +27 -9
  39. package/node_modules/checkride/dist/adapters.d.ts +12 -4
  40. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  41. package/node_modules/checkride/dist/adapters.js +21 -1
  42. package/node_modules/checkride/dist/adapters.js.map +1 -1
  43. package/node_modules/checkride/dist/agent-setup/cursor.d.ts +9 -1
  44. package/node_modules/checkride/dist/agent-setup/cursor.d.ts.map +1 -1
  45. package/node_modules/checkride/dist/agent-setup/cursor.js +59 -1
  46. package/node_modules/checkride/dist/agent-setup/cursor.js.map +1 -1
  47. package/node_modules/checkride/dist/agent-setup/harness.d.ts +1 -1
  48. package/node_modules/checkride/dist/agent-setup/harness.d.ts.map +1 -1
  49. package/node_modules/checkride/dist/agent-setup/harness.js +10 -1
  50. package/node_modules/checkride/dist/agent-setup/harness.js.map +1 -1
  51. package/node_modules/checkride/dist/agent-setup/scripts.d.ts +23 -0
  52. package/node_modules/checkride/dist/agent-setup/scripts.d.ts.map +1 -1
  53. package/node_modules/checkride/dist/agent-setup/scripts.js +387 -82
  54. package/node_modules/checkride/dist/agent-setup/scripts.js.map +1 -1
  55. package/node_modules/checkride/dist/baseline/fallow.js +2 -2
  56. package/node_modules/checkride/dist/baseline/fingerprint.d.ts +14 -5
  57. package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -1
  58. package/node_modules/checkride/dist/baseline/fingerprint.js +58 -5
  59. package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -1
  60. package/node_modules/checkride/dist/baseline/history.d.ts +98 -0
  61. package/node_modules/checkride/dist/baseline/history.d.ts.map +1 -0
  62. package/node_modules/checkride/dist/baseline/history.js +232 -0
  63. package/node_modules/checkride/dist/baseline/history.js.map +1 -0
  64. package/node_modules/checkride/dist/baseline/index.d.ts +4 -2
  65. package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -1
  66. package/node_modules/checkride/dist/baseline/index.js +2 -1
  67. package/node_modules/checkride/dist/baseline/index.js.map +1 -1
  68. package/node_modules/checkride/dist/baseline/store.d.ts +30 -7
  69. package/node_modules/checkride/dist/baseline/store.d.ts.map +1 -1
  70. package/node_modules/checkride/dist/baseline/store.js +50 -16
  71. package/node_modules/checkride/dist/baseline/store.js.map +1 -1
  72. package/node_modules/checkride/dist/cli.d.ts +5 -1
  73. package/node_modules/checkride/dist/cli.d.ts.map +1 -1
  74. package/node_modules/checkride/dist/cli.js +98 -25
  75. package/node_modules/checkride/dist/cli.js.map +1 -1
  76. package/node_modules/checkride/dist/config.d.ts +33 -4
  77. package/node_modules/checkride/dist/config.d.ts.map +1 -1
  78. package/node_modules/checkride/dist/config.js +21 -6
  79. package/node_modules/checkride/dist/config.js.map +1 -1
  80. package/node_modules/checkride/dist/digest/digest.js +1 -1
  81. package/node_modules/checkride/dist/doctor.d.ts +2 -0
  82. package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
  83. package/node_modules/checkride/dist/doctor.js +39 -1
  84. package/node_modules/checkride/dist/doctor.js.map +1 -1
  85. package/node_modules/checkride/dist/gate.d.ts +49 -4
  86. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  87. package/node_modules/checkride/dist/gate.js +143 -16
  88. package/node_modules/checkride/dist/gate.js.map +1 -1
  89. package/node_modules/checkride/dist/init.d.ts +8 -1
  90. package/node_modules/checkride/dist/init.d.ts.map +1 -1
  91. package/node_modules/checkride/dist/init.js +68 -7
  92. package/node_modules/checkride/dist/init.js.map +1 -1
  93. package/node_modules/checkride/dist/node-pin.d.ts +1 -1
  94. package/node_modules/checkride/dist/node-pin.js +1 -1
  95. package/node_modules/checkride/dist/orchestrator.d.ts +13 -0
  96. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  97. package/node_modules/checkride/dist/orchestrator.js +118 -13
  98. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  99. package/node_modules/checkride/dist/pm/index.d.ts +1 -0
  100. package/node_modules/checkride/dist/pm/index.d.ts.map +1 -1
  101. package/node_modules/checkride/dist/pm/index.js.map +1 -1
  102. package/node_modules/checkride/dist/pm/launch.d.ts +23 -2
  103. package/node_modules/checkride/dist/pm/launch.d.ts.map +1 -1
  104. package/node_modules/checkride/dist/pm/launch.js +16 -0
  105. package/node_modules/checkride/dist/pm/launch.js.map +1 -1
  106. package/node_modules/checkride/dist/pm/translate.js +1 -1
  107. package/node_modules/checkride/dist/pm/translate.js.map +1 -1
  108. package/node_modules/checkride/dist/qa/mutation.d.ts +4 -4
  109. package/node_modules/checkride/dist/qa/mutation.js +4 -4
  110. package/node_modules/checkride/dist/recover.d.ts +48 -0
  111. package/node_modules/checkride/dist/recover.d.ts.map +1 -0
  112. package/node_modules/checkride/dist/recover.js +220 -0
  113. package/node_modules/checkride/dist/recover.js.map +1 -0
  114. package/node_modules/checkride/dist/smoke.d.ts +1 -1
  115. package/node_modules/checkride/dist/smoke.js +1 -1
  116. package/node_modules/checkride/dist/snippets.d.ts +1 -1
  117. package/node_modules/checkride/package.json +3 -2
  118. package/node_modules/checkride/schema/checkride.config.schema.json +9 -1
  119. package/node_modules/checkride/skills/check/SKILL.md +9 -1
  120. package/node_modules/checkride/templates/shared/styles/Repo/Drift.yml +41 -0
  121. package/node_modules/checkride/templates/shared/styles/Repo/Latin.yml +14 -0
  122. package/node_modules/checkride/templates/shared/styles/Repo/LyHyphen.yml +28 -0
  123. package/node_modules/checkride/templates/shared/styles/Repo/Minted.yml +33 -0
  124. package/node_modules/checkride/templates/shared/styles/Repo/ThereIs.yml +16 -0
  125. package/node_modules/checkride/templates/shared/styles/Repo/Weasel.yml +26 -0
  126. package/node_modules/checkride/templates/shared/vale.ini +34 -0
  127. package/package.json +13 -11
  128. package/skills/abandon/SKILL.md +20 -0
  129. package/skills/build/SKILL.md +69 -64
  130. package/skills/doctor/SKILL.md +11 -11
  131. package/skills/finish/SKILL.md +20 -19
  132. package/skills/harvest/SKILL.md +13 -12
  133. package/skills/park/SKILL.md +10 -10
  134. package/skills/plan/SKILL.md +63 -54
  135. package/skills/recover/SKILL.md +7 -7
  136. package/skills/refine/SKILL.md +18 -16
  137. package/skills/revert/SKILL.md +6 -6
  138. package/skills/spike/SKILL.md +9 -9
  139. package/skills/status/SKILL.md +5 -5
  140. package/skills/step/SKILL.md +17 -12
  141. package/skills/verify/SKILL.md +45 -40
  142. package/templates/build-log.md +7 -7
  143. package/templates/intent.md +49 -39
  144. package/templates/spike-report.md +10 -10
@@ -1,34 +1,34 @@
1
- // `plumbbob doctor` — the health report, five sections 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
- // the skills, and the hook — and names the fix for anything missing. The failure
7
+ // the skills, and the hook, and names the fix for anything missing. The failure
8
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
11
  // 2. The repo sidecar layout. plumbbob keeps its per-repo state in a `.plumbbob/`
12
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
13
+ // per build (intent, build-log, checkpoints, report) that rides its branch into
14
14
  // the PR) and a git-excluded control plane of per-worktree files. A repo scaffolded
15
15
  // by an older plumbbob carries the legacy FLAT sidecar instead (`.plumbbob/intent.md`,
16
16
  // `config`, `archive/`), fully git-excluded, whose record dies with
17
17
  // `git worktree remove`. doctor detects it and, with `--migrate`, moves it into the
18
18
  // tracked `builds/<slug>/` layout: archive entries and the active session become
19
19
  // build folders, `config` becomes `settings.json`, and the whole move is STAGED but
20
- // never committed — the human owns that commit.
20
+ // never committed: the human owns that commit.
21
21
  //
22
22
  // 3. Agent validation. Every resolvable user-authored agent gets its manifest and its
23
23
  // `command` statically checked, so a broken agent surfaces here rather than mid-build.
24
24
  //
25
25
  // 4. The check gate. When a `check` setting overrides checkride (the default heavy
26
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
27
+ // prints the slot/adapter table: "detected but tool missing" is the footgun this
28
28
  // exists for.
29
29
  //
30
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
31
+ // can latch on a recorded human turn) or dormant (guidance only): dormant is a
32
32
  // state, never a failure.
33
33
  //
34
34
  // Functional, node builtins plus checkride.
@@ -44,7 +44,7 @@ import { findRepoRoot, gitPath, stagePath } from "../lib/git.js";
44
44
  import { buildDir, excludeControl, listBuilds, readTurn, setActiveBuild, sidecarDir, slugify } from "../lib/sidecar.js";
45
45
  import { resolveBoolean, resolveString, settingsPath } from "../lib/settings.js";
46
46
  /**
47
- * Resolve a directory shipped inside the plumbbob package (e.g. its skills/).
47
+ * Resolve a directory shipped inside the plumbbob package (for example its skills/).
48
48
  *
49
49
  * Resolved off this module's URL so it lands the same from src/ (dev) and
50
50
  * dist/ (published).
@@ -103,10 +103,10 @@ function buildChecks(link, pkg, shipped) {
103
103
  /**
104
104
  * The plugin-link diagnostic: how plumbbob is installed into Claude Code, as checks.
105
105
  *
106
- * Two install kinds are recognized: the marketplace plugin (one green line — skills
106
+ * Two install kinds are recognized: the marketplace plugin (one green line: skills
107
107
  * load and the CLI rides the plugin's bin/, nothing to verify piecemeal) and the
108
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
109
+ * once is a collision (two plugins named plumbbob fight over /plumbbob:*), reported
110
110
  * first, with the keep-one fix.
111
111
  */
112
112
  function pluginChecks() {
@@ -148,7 +148,7 @@ function subdirs(dir) {
148
148
  * Detect a legacy flat sidecar, or null when the repo is on the current layout
149
149
  * (or has no sidecar).
150
150
  *
151
- * `config` and `archive/` are unambiguous legacy markers — the builds/ layout has
151
+ * `config` and `archive/` are unambiguous legacy markers: the builds/ layout has
152
152
  * neither. A flat `intent.md` alone would also match today's `--local` layout, so it
153
153
  * only counts as legacy when the repo is NOT already migrated (no `builds/`, no
154
154
  * `settings.json`): that guard is what keeps `--local` untouched.
@@ -166,7 +166,7 @@ export function inspectLegacy(root) {
166
166
  return { config, archive, session };
167
167
  }
168
168
  /**
169
- * The first `# Heading` in a flat intent.md — the build title the slug derives from.
169
+ * The first `# Heading` in a flat intent.md: the build title the slug derives from.
170
170
  */
171
171
  function titleFromIntent(path) {
172
172
  try {
@@ -200,7 +200,7 @@ function configCheck(path) {
200
200
  /**
201
201
  * A slug not already claimed by `taken`, suffixing `-2`, `-3`, … only on collision.
202
202
  *
203
- * `start` refuses a colliding slug outright — the human picks a new title — but
203
+ * `start` refuses a colliding slug outright (the human picks a new title), but
204
204
  * migration is mechanically moving folders that already exist, so it disambiguates
205
205
  * rather than aborting mid-move.
206
206
  */
@@ -245,15 +245,18 @@ function narrowExcludes(root) {
245
245
  writeFileSync(exclude, kept.join('\n'));
246
246
  }
247
247
  catch {
248
- /* no exclude yet — excludeControl creates it */
248
+ /* no exclude yet: excludeControl creates it */
249
249
  }
250
250
  excludeControl(root);
251
251
  }
252
252
  /**
253
253
  * Perform the migration and return a human-readable list of what moved.
254
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.
255
+ * STAGES the result but never commits: the human owns that commit. In a repo
256
+ * that gitignores the sidecar the stage is skipped (stagePath's record-only
257
+ * guard) and the report says so: the files move but stay untracked. Call only
258
+ * when `inspectLegacy` reported a legacy layout; doctor prints the returned
259
+ * actions.
257
260
  */
258
261
  export function migrateSidecar(root) {
259
262
  const dir = sidecarDir(root);
@@ -263,7 +266,7 @@ export function migrateSidecar(root) {
263
266
  const configPath = join(dir, 'config');
264
267
  if (existsSync(configPath)) {
265
268
  if (!existsSync(settingsPath(root))) {
266
- // Carry forward ONLY what the legacy config actually held — a `check` line,
269
+ // Carry forward ONLY what the legacy config actually held: a `check` line,
267
270
  // nothing else. No invented `auto` (the config never had one, it defaults to
268
271
  // false, and it belongs in settings.local.json): migration yields exactly what
269
272
  // a fresh `start` would, plus the check. An empty config → an empty `{}`.
@@ -297,8 +300,10 @@ export function migrateSidecar(root) {
297
300
  }
298
301
  rmSync(archiveDir, { recursive: true, force: true });
299
302
  narrowExcludes(root);
300
- stagePath(root, '.plumbbob');
301
- actions.push('staged the move (builds/ + settings.json) — commit it yourself');
303
+ const staged = stagePath(root, '.plumbbob');
304
+ actions.push(staged
305
+ ? 'staged the move (builds/ + settings.json) — commit it yourself'
306
+ : 'moved but not staged (the sidecar is gitignored) — the files stay untracked');
302
307
  return actions;
303
308
  }
304
309
  /**
@@ -336,7 +341,7 @@ function sidecarReport(cwd, migrate) {
336
341
  /**
337
342
  * Interpreters recognized as the program of a manifest `command`: when the command
338
343
  * 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
344
+ * argument (the interpreter reads it: the file need not carry +x), not the
340
345
  * interpreter, which we trust to be on PATH.
341
346
  */
342
347
  const INTERPRETERS = new Set(['sh', 'bash', 'zsh', 'dash', 'node', 'deno', 'bun', 'python', 'python3', 'ruby', 'perl']);
@@ -344,7 +349,7 @@ const INTERPRETERS = new Set(['sh', 'bash', 'zsh', 'dash', 'node', 'deno', 'bun'
344
349
  * Expand PLUMBBOB_AGENT_DIR in a command and split it into bare tokens
345
350
  * (surrounding quotes stripped).
346
351
  *
347
- * A deliberately shallow split — enough for the static command check below, not a
352
+ * A deliberately shallow split: enough for the static command check below, not a
348
353
  * shell parser (which is neither possible nor wanted for a best-effort diagnostic).
349
354
  */
350
355
  function commandTokens(command, agentDir) {
@@ -356,11 +361,11 @@ function commandTokens(command, agentDir) {
356
361
  }
357
362
  /**
358
363
  * 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.
364
+ * does not exist, or a directly invoked one missing its +x bit.
360
365
  *
361
366
  * The command is a shell string spawned via `sh -c` with the repo root as cwd, so a
362
367
  * 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
368
+ * own dir. Best-effort by design: a path-shaped program or an interpreter's script
364
369
  * argument is checked; a bare command (a PATH binary, an inline `node -e` program)
365
370
  * is trusted, because we cannot prove a shell line broken without running it.
366
371
  * Returns the problem string, or null when nothing checkable is wrong.
@@ -443,7 +448,7 @@ function agentReport(cwd) {
443
448
  *
444
449
  * A configured `check` setting names the spawn override and asks nothing more;
445
450
  * 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")
451
+ * checkride marks `required` count as problems; an empty slot ("no tool detected")
447
452
  * is informational, not a failure, because the runtime gate already refuses a
448
453
  * vacuous run.
449
454
  */
@@ -474,7 +479,7 @@ async function gateReport(cwd, env) {
474
479
  failed += 1;
475
480
  }
476
481
  // 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
482
+ // checkpoints would be gated only by checkride's always-on repo checks: either
478
483
  // a vacuous refusal or, worse, a green that tested nothing. Informational, with
479
484
  // the exact fix; `start` surfaces the same probe at plan time.
480
485
  if (!gateDetectsTools(report.checks)) {
@@ -490,7 +495,8 @@ async function gateReport(cwd, env) {
490
495
  }
491
496
  /**
492
497
  * 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.
498
+ * hint, or `○ spell` joined by an em dash to "no tool detected" for an empty
499
+ * (skipping) slot.
494
500
  */
495
501
  function toolRow(c) {
496
502
  if (c.adapter === null || c.adapter === undefined) {
@@ -507,8 +513,8 @@ function toolRow(c) {
507
513
  * The latch is `checkpoint`'s refusal to land a step until the harness has recorded
508
514
  * a human turn since the step was entered. `.plumbbob/TURN` holds a count once the
509
515
  * 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
516
+ * is armed; its absence means the tick is guidance-only. Dormant is a legitimate
517
+ * state (a host with no hooks simply has no ledger), never a failure, so this
512
518
  * section adds nothing to the problem count. The dormant hint names the hook because
513
519
  * a missing ledger almost always means it never wired, and that hook rides in either
514
520
  * install kind (the marketplace plugin's hooks/hooks.json or an init-style link).
@@ -525,9 +531,9 @@ function latchReport(cwd) {
525
531
  : ` ✓ latch: live (turn ${turn})`;
526
532
  const lines = ['', 'plumbbob doctor — approval latch — D64 (approval-latch)', line];
527
533
  // 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
534
+ // the model can forge is no grant: self-approval comes only from the human's
529
535
  // typed `/build --auto`. Surface a set value so a human relying on it isn't
530
- // silently changed — informational, never a problem.
536
+ // silently changed: informational, never a problem.
531
537
  if (resolveBoolean(root, 'auto', false)) {
532
538
  lines.push(' ○ auto: set in settings but not a grant since D67 (auto-not-a-grant) — self-approve per run with `/plumbbob:build --auto`');
533
539
  }
@@ -1,9 +1,9 @@
1
- // `plumbbob finish` — the close-out. It appends the checkpoint SHAs and per-step
1
+ // `plumbbob finish`: the close-out. It appends the checkpoint SHAs and per-step
2
2
  // stats to report.md, makes the final commit, and clears the control state (the
3
3
  // untracked session and step markers under `.plumbbob/`). The build folder is NOT
4
- // deleted — the tracked `.plumbbob/builds/<slug>/` folder IS the archive: it
4
+ // deleted; the tracked `.plumbbob/builds/<slug>/` folder IS the archive: it
5
5
  // merges with the branch and shows up in the PR, so nothing is copied into a
6
- // separate local archive. A missing report never blocks the exit — guidance
6
+ // separate local archive. A missing report never blocks the exit: guidance
7
7
  // offers the artifact, it does not wall the door. The git footprint stays
8
8
  // additive: one forward commit under the Conventional `chore(<scope>): finish`
9
9
  // subject, with the `plumbbob finish` identifier riding a marker line at the head
@@ -11,6 +11,7 @@
11
11
  import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
  import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
14
+ import { readCommitBody } from "../lib/commitbody.js";
14
15
  import { buildScope, checkpointsPath, clearTick, hasSession, intentPath, readStats, refreshExcludes, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
15
16
  import { conventionalSubject, withMarker } from "../lib/commitmsg.js";
16
17
  import { parseBuildScope } from "../lib/intent.js";
@@ -19,7 +20,7 @@ import { parseBuildScope } from "../lib/intent.js";
19
20
  *
20
21
  * Requires an active session (the `.plumbbob/STATE` sentinel). When report.md
21
22
  * exists it gains a `## Checkpoints` SHA list and a `## Stats` table first; when
22
- * it doesn't, finish notes the absence and proceeds — the exit is never gated on
23
+ * it doesn't, finish notes the absence and proceeds; the exit is never gated on
23
24
  * a report.
24
25
  */
25
26
  export function finish(cwd, args = []) {
@@ -28,6 +29,15 @@ export function finish(cwd, args = []) {
28
29
  process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
29
30
  return 1;
30
31
  }
32
+ // Checked before any of finish's writes: appendCheckpointShas/appendStats
33
+ // are plain appends, not idempotent, so a refusal after they ran would
34
+ // duplicate those sections on retry. A socket stdin can never deliver the
35
+ // requested body, so there is nothing to do first.
36
+ const bodyResult = readCommitBody(args);
37
+ if (!bodyResult.ok) {
38
+ process.stderr.write(bodyResult.message);
39
+ return 1;
40
+ }
31
41
  const { build: slug } = resolveBuild(root, args);
32
42
  if (existsSync(reportPath(root, slug))) {
33
43
  appendCheckpointShas(root, slug);
@@ -37,9 +47,9 @@ export function finish(cwd, args = []) {
37
47
  process.stderr.write('plumbbob: note — no report.md found; finishing without one ' +
38
48
  '(/plumbbob:finish normally writes the report first). No gate — D9 (finish-no-gate).\n');
39
49
  }
40
- // The final commit: stage the report just written plus the build folder's tail —
41
- // a step's commit sweeps its own bookkeeping along with the work, so the last
42
- // step's `checkpoints` line lands one commit late and finish absorbs it — and
50
+ // The final commit: stage the report just written plus the build folder's tail
51
+ // (a step's commit sweeps its own bookkeeping along with the work, so the last
52
+ // step's `checkpoints` line lands one commit late and finish absorbs it) and
43
53
  // commit under the `finish` subject. `--allow-empty` (via `commit`) still marks
44
54
  // the narrative endpoint when the tree is already clean, or under `--local`,
45
55
  // where the whole sidecar is git-excluded and there is nothing tracked to stage.
@@ -47,18 +57,18 @@ export function finish(cwd, args = []) {
47
57
  if (isDirty(root)) {
48
58
  stageAll(root);
49
59
  }
50
- const sha = commit(root, subject(root, slug), withMarker('plumbbob finish', bodyArg(args) ?? undefined));
60
+ const sha = commit(root, subject(root, slug), withMarker('plumbbob finish', bodyResult.body ?? undefined));
51
61
  // Clear the control state: the in-flight markers first, then the session
52
62
  // sentinel last (so "no session" flips exactly at the end). Deleting STATE also
53
- // drops the active-build cursor — cursor and session share that one file, so a
54
- // single delete does both. The tracked artifacts stay in place — only the
63
+ // drops the active-build cursor: cursor and session share that one file, so a
64
+ // single delete does both. The tracked artifacts stay in place; only the
55
65
  // ephemera go.
56
66
  rmSync(seamPath(root, slug), { force: true });
57
67
  rmSync(stepPath(root, slug), { force: true });
58
68
  rmSync(spikePath(root, slug), { force: true });
59
69
  // The checkpoint latch's per-build entry stamp and the one-turn self-approval
60
70
  // grant go with the session: a grant lives one turn by construction, but only
61
- // because every tick rewrites it — the session's last tick was the last rewrite,
71
+ // because every tick rewrites it: the session's last tick was the last rewrite,
62
72
  // so left behind, a stale `auto` could self-approve the next session's first
63
73
  // landing.
64
74
  clearTick(root, slug);
@@ -74,7 +84,7 @@ export function finish(cwd, args = []) {
74
84
  *
75
85
  * The scope resolves through the same build-default fallback chain as the plan
76
86
  * and step subjects: the intent.md `**Scope:**` header field, else the build
77
- * slug, else bare (e.g. `--local`). The `plumbbob finish` identifier rides the
87
+ * slug, else bare (for example `--local`). The `plumbbob finish` identifier rides the
78
88
  * body marker, not the subject.
79
89
  */
80
90
  function subject(root, slug) {
@@ -83,7 +93,7 @@ function subject(root, slug) {
83
93
  /**
84
94
  * Resolve the commit scope: the intent.md `**Scope:**` header, else the slug.
85
95
  *
86
- * Twin of checkpoint.ts's helper — same fallback chain, but with this verb's own
96
+ * Twin of checkpoint.ts's helper: same fallback chain, but with this verb's own
87
97
  * `slug` resolution instead of the active-build cursor.
88
98
  */
89
99
  function buildDefaultScope(root, slug) {
@@ -94,38 +104,15 @@ function buildDefaultScope(root, slug) {
94
104
  }
95
105
  }
96
106
  catch {
97
- // no intent.md — fall through to the slug rung.
107
+ // no intent.md: fall through to the slug rung.
98
108
  }
99
109
  return buildScope(slug);
100
110
  }
101
- /**
102
- * Read the `--body` final-commit body from stdin, or null.
103
- *
104
- * The body arrives as a single-quoted stdin heredoc — the CLI owns every commit
105
- * subject, but the finish skill composes a proportional close-out body this
106
- * way. Returns null when the flag is absent or stdin is empty; the commit then
107
- * carries subject only. Reading fd 0 blocks until EOF, which the heredoc
108
- * supplies; a read error (no stdin attached) degrades to null, and an
109
- * interactive TTY — which would never send EOF — skips the read instead of
110
- * hanging (twin of checkpoint.ts's guard).
111
- */
112
- function bodyArg(args) {
113
- if (!args.includes('--body') || process.stdin.isTTY === true) {
114
- return null;
115
- }
116
- try {
117
- const raw = readFileSync(0, 'utf8').trimEnd();
118
- return raw.length > 0 ? raw : null;
119
- }
120
- catch {
121
- return null;
122
- }
123
- }
124
111
  /**
125
112
  * Roll the per-step receipts into report.md as a `## Stats` table.
126
113
  *
127
- * One row per step plus totals — red checks, drift warnings, reverts,
128
- * wall-clock — so "is the loop worth it?" is a table, not a feeling. Silently
114
+ * One row per step plus totals (red checks, drift warnings, reverts,
115
+ * wall-clock) so "is the loop worth it?" is a table, not a feeling. Silently
129
116
  * skipped when nothing accrued (a build with no red/revert/drift and no
130
117
  * `build <n>` stamps has nothing to say).
131
118
  */
@@ -173,7 +160,7 @@ function wallClockMs(s) {
173
160
  /**
174
161
  * Format a wall-clock duration for the stats table.
175
162
  *
176
- * `—` when unknown (a hand-built step never ran `build <n>`, so it has no
163
+ * An em dash when unknown (a hand-built step never ran `build <n>`, so it has no
177
164
  * startedAt), `<1m` under a minute, whole minutes otherwise.
178
165
  */
179
166
  function formatWall(ms) {
@@ -1,8 +1,8 @@
1
- // `plumbbob handoff` — print the standardized build hand-off block: the
1
+ // `plumbbob handoff`: print the standardized build hand-off block: the
2
2
  // "state / choice / what's next" the human sees at each step boundary. Read-only,
3
- // no state change. It derives the moment from the session — a step in flight ⇒ the
3
+ // no state change. It derives the moment from the session: a step in flight ⇒ the
4
4
  // pause block (built → looks-good/needs-work → next), none ⇒ the post-checkpoint
5
- // boundary block — and renders the next undone step plus its advisory `- model:`
5
+ // boundary block, and renders the next undone step plus its advisory `- model:`
6
6
  // recommendation straight from intent.md, the build's plan document. Owning the
7
7
  // block here, rather than as prose in the build skill, keeps the skill from
8
8
  // drifting out of sync with `status`, which renders the same next-step detail
@@ -75,7 +75,7 @@ function boundaryBlock(step, title, nextUp) {
75
75
  * The forward pointer, shared by both blocks: the next undone step, its title,
76
76
  * and its advisory `- model:` recommendation.
77
77
  *
78
- * The recommendation renders as just the model token for the `/model` call —
78
+ * The recommendation renders as just the model token for the `/model` call;
79
79
  * the full rationale lives on the `status` dashboard. No next step ⇒ the
80
80
  * finish/step nudge instead.
81
81
  */
@@ -101,8 +101,9 @@ function titleOf(steps, n) {
101
101
  return steps.find((s) => s.n === n)?.title ?? null;
102
102
  }
103
103
  /**
104
- * The model token from a `- model:` recommendation — the first word, so a
105
- * `model: opus — <rationale>` line yields `opus` for the `/model` call.
104
+ * The model token from a `- model:` recommendation: the first word, so a
105
+ * `model: opus` line followed by an em dash and rationale yields `opus` for
106
+ * the `/model` call.
106
107
  *
107
108
  * Null when there is no recommendation, or it degraded to whitespace (folding
108
109
  * the null model into the same guard keeps both branches live).
@@ -112,7 +113,7 @@ function modelToken(model) {
112
113
  return first !== undefined && first.length > 0 ? first : null;
113
114
  }
114
115
  /**
115
- * Read a file as UTF-8, or return '' when it is missing or unreadable — an
116
+ * Read a file as UTF-8, or return '' when it is missing or unreadable: an
116
117
  * absent sidecar file reads as empty rather than throwing.
117
118
  */
118
119
  function readOr(path) {
@@ -124,7 +125,7 @@ function readOr(path) {
124
125
  }
125
126
  }
126
127
  /**
127
- * The in-flight step number from the STEP marker — the flat one-line file in
128
+ * The in-flight step number from the STEP marker: the flat one-line file in
128
129
  * the build folder that records which step is open. Null when no step is in
129
130
  * flight or the file holds anything but a bare number.
130
131
  */
@@ -1,7 +1,7 @@
1
- // `plumbbob init [--uninstall] [--force]` — link plumbbob into Claude Code as an
1
+ // `plumbbob init [--uninstall] [--force]`: link plumbbob into Claude Code as an
2
2
  // in-place skills-directory plugin. It symlinks the installed package into
3
- // ~/.claude/skills/plumbbob, where Claude Code discovers it as `plumbbob@skills-dir`
4
- // — the skills load namespaced (`/plumbbob:*`) and the post-edit hook auto-registers
3
+ // ~/.claude/skills/plumbbob, where Claude Code discovers it as `plumbbob@skills-dir`:
4
+ // the skills load namespaced (`/plumbbob:*`) and the post-edit hook auto-registers
5
5
  // from hooks/hooks.json. This non-marketplace install path is first-class by design:
6
6
  // it serves an `npm i -g` global, local dev, and clients predating plugins, and it
7
7
  // keeps plumbbob usable as a standalone CLI outside any one host rather than
@@ -10,9 +10,9 @@
10
10
  // via bin/, so it needs neither `npm i -g` nor `init`). The two are mutually
11
11
  // exclusive: both register a plugin named `plumbbob`, and a double-install collides
12
12
  // over the /plumbbob:* namespace (skills can drop to flat `/status` names). So init
13
- // REFUSES when a marketplace plumbbob is already installed — `--force` overrides
13
+ // REFUSES when a marketplace plumbbob is already installed: `--force` overrides
14
14
  // (the dev-install path uses it). Global-only by design: install scope is NOT session
15
- // scope — sessions stay per-project via `plumbbob start`. Idempotent + reversible
15
+ // scope: sessions stay per-project via `plumbbob start`. Idempotent + reversible
16
16
  // (`--uninstall` drops the link); it NEVER writes settings.json. Node builtins only.
17
17
  import { lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
@@ -20,7 +20,7 @@ import { join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { marketplacePlumbbob } from "../lib/plugins.js";
22
22
  /**
23
- * The installed package root — parent of .claude-plugin/, skills/, hooks/, dist/.
23
+ * The installed package root: parent of .claude-plugin/, skills/, hooks/, dist/.
24
24
  *
25
25
  * Derived from this module's URL: the global install when run as the published
26
26
  * bin, the checkout in dev. No trailing slash, so it compares clean against a
@@ -64,8 +64,8 @@ function symlinkTarget(path) {
64
64
  /**
65
65
  * Link the installed package into ~/.claude/skills/plumbbob, or `--uninstall` it.
66
66
  *
67
- * Refuses when a marketplace plumbbob plugin is already installed — two plugins
68
- * named `plumbbob` collide over the /plumbbob:* namespace — unless `--force`.
67
+ * Refuses when a marketplace plumbbob plugin is already installed (two plugins
68
+ * named `plumbbob` collide over the /plumbbob:* namespace) unless `--force`.
69
69
  * Repoints a stale link from an earlier install location; already-linked is a
70
70
  * clean exit 0.
71
71
  */
@@ -95,7 +95,7 @@ export function init(args) {
95
95
  process.stdout.write(`plumbbob: already linked (${link} → ${target}). Nothing to do.\n`);
96
96
  return 0;
97
97
  }
98
- rmSync(link); // a stale link from an earlier install location — repoint it
98
+ rmSync(link); // a stale link from an earlier install location: repoint it
99
99
  }
100
100
  symlinkSync(target, link);
101
101
  process.stdout.write(`plumbbob: linked ${link} → ${target}.\n` +
@@ -1,6 +1,6 @@
1
- // `plumbbob park "<text>"` — the raw capture path for a thought you don't want
1
+ // `plumbbob park "<text>"`: the raw capture path for a thought you don't want
2
2
  // to chase mid-step: append one flat line under the "## Park list" section of
3
- // build-log.md (the build's human-facing ledger). Capture stays dumb by design —
3
+ // build-log.md (the build's human-facing ledger). Capture stays dumb by design:
4
4
  // a grep-readable append, no markdown parsing, no model turn (wording the line
5
5
  // well is /plumbbob:park's job); triage waits for a step boundary, via harvest.
6
6
  // Capture is not a state transition, so it runs in any context (terminal or
@@ -14,7 +14,7 @@ import { appendToSection } from "../lib/buildlog.js";
14
14
  *
15
15
  * All non-flag args join into a single line. Refuses when there is no active
16
16
  * session, when the text is empty, or when build-log.md lacks a "## Park list"
17
- * section — a park line must never vanish silently.
17
+ * section: a park line must never vanish silently.
18
18
  */
19
19
  export function park(cwd, args) {
20
20
  const root = findRepoRoot(cwd);
@@ -1,8 +1,8 @@
1
- // `plumbbob recover` — read the control plane and say, plainly, whether it is
1
+ // `plumbbob recover`: read the control plane and say, plainly, whether it is
2
2
  // telling the truth. Nothing else reads these files as a set: `doctor` checks
3
3
  // the install and the gate, `status` reports what the markers claim without
4
4
  // questioning them. So a session that crashed, lost its context window, or was
5
- // switched away mid-step can sit in a state no verb detects — the worst being a
5
+ // switched away mid-step can sit in a state no verb detects: the worst being a
6
6
  // cursor pointing at a build that no longer exists, which renders a perfectly
7
7
  // plausible empty dashboard instead of an error.
8
8
  //
@@ -10,7 +10,7 @@
10
10
  // `doctor --migrate` takes. What `--fix` may touch is deliberately narrow: only
11
11
  // the untracked control files this tool owns and can rewrite from what it
12
12
  // already knows. It never touches a tracked artifact (intent, build-log,
13
- // checkpoints, reports), never touches git, and never advances the loop — a
13
+ // checkpoints, reports), never touches git, and never advances the loop: a
14
14
  // recovery verb that could land a step would be a second, quieter checkpoint.
15
15
  //
16
16
  // Spike leftovers are reported and never removed: those worktrees live outside
@@ -26,7 +26,7 @@ import { spikeBranches, spikeWorktrees } from "./spike.js";
26
26
  * ones that can be repaired without judgment.
27
27
  *
28
28
  * Exits 0 when the control plane is consistent (or when every problem found was
29
- * fixed), 1 while any problem is still standing — so a scripted caller can tell
29
+ * fixed), 1 while any problem is still standing, so a scripted caller can tell
30
30
  * "clean" from "you still have to look at this".
31
31
  */
32
32
  export function recover(cwd, args) {
@@ -75,7 +75,7 @@ function summary(standing, fixable, fix) {
75
75
  * Everything that depends on there being a session: the cursor, the phase
76
76
  * markers, and the per-build control files.
77
77
  *
78
- * With no session there is no control plane to be wrong about — only the spike
78
+ * With no session there is no control plane to be wrong about: only the spike
79
79
  * leftovers, which outlive `finish` and are checked separately.
80
80
  */
81
81
  function sessionFindings(root) {
@@ -101,7 +101,7 @@ function sessionFindings(root) {
101
101
  *
102
102
  * This is the quiet one: `status` takes the cursor at its word, so every read
103
103
  * comes back empty and the dashboard renders as a fresh, untouched build rather
104
- * than an error. Repairable only when exactly one real build remains — with
104
+ * than an error. Repairable only when exactly one real build remains; with
105
105
  * several, which one you meant is a judgment call.
106
106
  */
107
107
  function cursorFinding(root, slug) {
@@ -128,7 +128,7 @@ function cursorFinding(root, slug) {
128
128
  * Contradictions in what phase the build claims to be in.
129
129
  *
130
130
  * Phase is derived from the markers rather than stored, so two markers at once
131
- * is not an impossible state — it is an unreadable one, and `status` resolves
131
+ * is not an impossible state; it is an unreadable one, and `status` resolves
132
132
  * it by showing the spike and hiding the step entirely.
133
133
  */
134
134
  function phaseFindings(root, slug, stepInFlight) {
@@ -159,7 +159,7 @@ function phaseFindings(root, slug, stepInFlight) {
159
159
  name: 'step marker',
160
160
  ok: false,
161
161
  detail: `step ${marker} is in flight but the plan has no step ${marker} (planned: ${planned.join(', ')})`,
162
- hint: 'the plan was rewritten under the step — sharpen it with /plumbbob:step, or plumbbob revert to drop the step',
162
+ hint: 'the plan was rewritten under the step — sharpen it with /plumbbob:step, plumbbob revert to drop the step and its work, or plumbbob abandon to drop the step but keep the work',
163
163
  });
164
164
  }
165
165
  }
@@ -175,7 +175,7 @@ function phaseFindings(root, slug, stepInFlight) {
175
175
  */
176
176
  function leftoverFindings(root, slug, stepInFlight) {
177
177
  const findings = [];
178
- // The handoff ledger is step-scoped and cleared by `checkpoint` alone — a
178
+ // The handoff ledger is step-scoped and cleared by `checkpoint` alone; a
179
179
  // revert or an abandoned step leaves it for the next step to inherit.
180
180
  const handoffs = handoffCount(root, slug);
181
181
  if (!stepInFlight && handoffs > 0) {
@@ -209,7 +209,7 @@ function leftoverFindings(root, slug, stepInFlight) {
209
209
  else {
210
210
  findings.push({ name: 'latch tick', ok: true, detail: stepInFlight ? 'stamped for the step in flight' : 'clear' });
211
211
  }
212
- // GRANT is rewritten on every turn tick, so it self-clears — but only while
212
+ // GRANT is rewritten on every turn tick, so it self-clears, but only while
213
213
  // the hook runs. Without a TURN ledger nothing will ever clear it, and the
214
214
  // latch reads a standing grant as self-approval.
215
215
  if (existsSync(grantPath(root)) && readTurn(root) === null) {
@@ -276,7 +276,7 @@ function handoffCount(root, slug) {
276
276
  }
277
277
  }
278
278
  /**
279
- * A control marker's trimmed content, or '?' when it cannot be read — a marker
279
+ * A control marker's trimmed content, or '?' when it cannot be read: a marker
280
280
  * exists to be reported on, so an unreadable one must not throw mid-report.
281
281
  */
282
282
  function readMarker(path) {
@@ -284,7 +284,7 @@ function readMarker(path) {
284
284
  return raw.length > 0 ? raw : '?';
285
285
  }
286
286
  /**
287
- * File content, or '' when absent — recover reads state that may be missing by
287
+ * File content, or '' when absent: recover reads state that may be missing by
288
288
  * definition, so every read degrades rather than throws.
289
289
  */
290
290
  function readOr(path) {