plumbbob 0.9.5 → 0.10.0

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 (142) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +87 -86
  3. package/dist/cli-core.js +8 -8
  4. package/dist/cli.js +1 -1
  5. package/dist/lib/agents.js +26 -26
  6. package/dist/lib/buildlog.js +11 -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 +54 -54
  18. package/dist/lib/templates.js +1 -1
  19. package/dist/verbs/agent.js +25 -25
  20. package/dist/verbs/build.js +9 -9
  21. package/dist/verbs/check.js +6 -6
  22. package/dist/verbs/checkpoint.js +48 -47
  23. package/dist/verbs/doctor.js +27 -26
  24. package/dist/verbs/finish.js +27 -40
  25. package/dist/verbs/handoff.js +9 -8
  26. package/dist/verbs/init.js +9 -9
  27. package/dist/verbs/park.js +3 -3
  28. package/dist/verbs/recover.js +11 -11
  29. package/dist/verbs/revert.js +12 -12
  30. package/dist/verbs/spike.js +17 -17
  31. package/dist/verbs/start.js +49 -19
  32. package/dist/verbs/status.js +8 -8
  33. package/dist/verbs/turn.js +16 -16
  34. package/dist/verbs/use.js +4 -4
  35. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  36. package/node_modules/checkride/CHANGELOG.md +401 -0
  37. package/node_modules/checkride/README.md +27 -9
  38. package/node_modules/checkride/dist/adapters.d.ts +12 -4
  39. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  40. package/node_modules/checkride/dist/adapters.js +21 -1
  41. package/node_modules/checkride/dist/adapters.js.map +1 -1
  42. package/node_modules/checkride/dist/agent-setup/cursor.d.ts +9 -1
  43. package/node_modules/checkride/dist/agent-setup/cursor.d.ts.map +1 -1
  44. package/node_modules/checkride/dist/agent-setup/cursor.js +59 -1
  45. package/node_modules/checkride/dist/agent-setup/cursor.js.map +1 -1
  46. package/node_modules/checkride/dist/agent-setup/harness.d.ts +1 -1
  47. package/node_modules/checkride/dist/agent-setup/harness.d.ts.map +1 -1
  48. package/node_modules/checkride/dist/agent-setup/harness.js +10 -1
  49. package/node_modules/checkride/dist/agent-setup/harness.js.map +1 -1
  50. package/node_modules/checkride/dist/agent-setup/scripts.d.ts +23 -0
  51. package/node_modules/checkride/dist/agent-setup/scripts.d.ts.map +1 -1
  52. package/node_modules/checkride/dist/agent-setup/scripts.js +387 -82
  53. package/node_modules/checkride/dist/agent-setup/scripts.js.map +1 -1
  54. package/node_modules/checkride/dist/baseline/fallow.js +2 -2
  55. package/node_modules/checkride/dist/baseline/fingerprint.d.ts +14 -5
  56. package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -1
  57. package/node_modules/checkride/dist/baseline/fingerprint.js +58 -5
  58. package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -1
  59. package/node_modules/checkride/dist/baseline/history.d.ts +98 -0
  60. package/node_modules/checkride/dist/baseline/history.d.ts.map +1 -0
  61. package/node_modules/checkride/dist/baseline/history.js +232 -0
  62. package/node_modules/checkride/dist/baseline/history.js.map +1 -0
  63. package/node_modules/checkride/dist/baseline/index.d.ts +4 -2
  64. package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -1
  65. package/node_modules/checkride/dist/baseline/index.js +2 -1
  66. package/node_modules/checkride/dist/baseline/index.js.map +1 -1
  67. package/node_modules/checkride/dist/baseline/store.d.ts +30 -7
  68. package/node_modules/checkride/dist/baseline/store.d.ts.map +1 -1
  69. package/node_modules/checkride/dist/baseline/store.js +50 -16
  70. package/node_modules/checkride/dist/baseline/store.js.map +1 -1
  71. package/node_modules/checkride/dist/cli.d.ts +5 -1
  72. package/node_modules/checkride/dist/cli.d.ts.map +1 -1
  73. package/node_modules/checkride/dist/cli.js +98 -25
  74. package/node_modules/checkride/dist/cli.js.map +1 -1
  75. package/node_modules/checkride/dist/config.d.ts +33 -4
  76. package/node_modules/checkride/dist/config.d.ts.map +1 -1
  77. package/node_modules/checkride/dist/config.js +21 -6
  78. package/node_modules/checkride/dist/config.js.map +1 -1
  79. package/node_modules/checkride/dist/digest/digest.js +1 -1
  80. package/node_modules/checkride/dist/doctor.d.ts +2 -0
  81. package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
  82. package/node_modules/checkride/dist/doctor.js +39 -1
  83. package/node_modules/checkride/dist/doctor.js.map +1 -1
  84. package/node_modules/checkride/dist/gate.d.ts +49 -4
  85. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  86. package/node_modules/checkride/dist/gate.js +143 -16
  87. package/node_modules/checkride/dist/gate.js.map +1 -1
  88. package/node_modules/checkride/dist/init.d.ts +8 -1
  89. package/node_modules/checkride/dist/init.d.ts.map +1 -1
  90. package/node_modules/checkride/dist/init.js +68 -7
  91. package/node_modules/checkride/dist/init.js.map +1 -1
  92. package/node_modules/checkride/dist/node-pin.d.ts +1 -1
  93. package/node_modules/checkride/dist/node-pin.js +1 -1
  94. package/node_modules/checkride/dist/orchestrator.d.ts +13 -0
  95. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  96. package/node_modules/checkride/dist/orchestrator.js +118 -13
  97. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  98. package/node_modules/checkride/dist/pm/index.d.ts +1 -0
  99. package/node_modules/checkride/dist/pm/index.d.ts.map +1 -1
  100. package/node_modules/checkride/dist/pm/index.js.map +1 -1
  101. package/node_modules/checkride/dist/pm/launch.d.ts +23 -2
  102. package/node_modules/checkride/dist/pm/launch.d.ts.map +1 -1
  103. package/node_modules/checkride/dist/pm/launch.js +16 -0
  104. package/node_modules/checkride/dist/pm/launch.js.map +1 -1
  105. package/node_modules/checkride/dist/pm/translate.js +1 -1
  106. package/node_modules/checkride/dist/pm/translate.js.map +1 -1
  107. package/node_modules/checkride/dist/qa/mutation.d.ts +4 -4
  108. package/node_modules/checkride/dist/qa/mutation.js +4 -4
  109. package/node_modules/checkride/dist/recover.d.ts +48 -0
  110. package/node_modules/checkride/dist/recover.d.ts.map +1 -0
  111. package/node_modules/checkride/dist/recover.js +220 -0
  112. package/node_modules/checkride/dist/recover.js.map +1 -0
  113. package/node_modules/checkride/dist/smoke.d.ts +1 -1
  114. package/node_modules/checkride/dist/smoke.js +1 -1
  115. package/node_modules/checkride/dist/snippets.d.ts +1 -1
  116. package/node_modules/checkride/package.json +3 -2
  117. package/node_modules/checkride/schema/checkride.config.schema.json +9 -1
  118. package/node_modules/checkride/skills/check/SKILL.md +9 -1
  119. package/node_modules/checkride/templates/shared/styles/Repo/Drift.yml +41 -0
  120. package/node_modules/checkride/templates/shared/styles/Repo/Latin.yml +14 -0
  121. package/node_modules/checkride/templates/shared/styles/Repo/LyHyphen.yml +28 -0
  122. package/node_modules/checkride/templates/shared/styles/Repo/Minted.yml +33 -0
  123. package/node_modules/checkride/templates/shared/styles/Repo/ThereIs.yml +16 -0
  124. package/node_modules/checkride/templates/shared/styles/Repo/Weasel.yml +26 -0
  125. package/node_modules/checkride/templates/shared/vale.ini +34 -0
  126. package/package.json +10 -9
  127. package/skills/build/SKILL.md +69 -64
  128. package/skills/doctor/SKILL.md +11 -11
  129. package/skills/finish/SKILL.md +20 -19
  130. package/skills/harvest/SKILL.md +13 -12
  131. package/skills/park/SKILL.md +10 -10
  132. package/skills/plan/SKILL.md +63 -54
  133. package/skills/recover/SKILL.md +6 -6
  134. package/skills/refine/SKILL.md +18 -16
  135. package/skills/revert/SKILL.md +6 -6
  136. package/skills/spike/SKILL.md +9 -9
  137. package/skills/status/SKILL.md +5 -5
  138. package/skills/step/SKILL.md +17 -12
  139. package/skills/verify/SKILL.md +45 -40
  140. package/templates/build-log.md +7 -7
  141. package/templates/intent.md +49 -39
  142. package/templates/spike-report.md +10 -10
@@ -1,18 +1,19 @@
1
- // `plumbbob checkpoint [<n>] [-m <msg>]` — the commit tick that lands a step.
1
+ // `plumbbob checkpoint [<n>] [-m <msg>]`: the commit tick that lands a step.
2
2
  // Executor-agnostic and author-blind: it reads the diff, not who wrote it, so a
3
3
  // `/plumbbob:build` run, your own hands, a vibe session, or another harness all
4
4
  // checkpoint identically. It does NOT require a STEP marker (the flat control
5
5
  // file recording the step in flight): the step is whatever you pass, else the
6
6
  // in-flight STEP, else the next undone step in intent.md. It evaluates the
7
7
  // approval latch, gates on a green check, commits any pending work (or records
8
- // the existing HEAD when the tree is already clean — the human's commit skill
8
+ // the existing HEAD when the tree is already clean: the human's commit skill
9
9
  // may have committed first), records the SHA, flips the intent checkbox to
10
- // `[x]`, and clears the STEP/SEAM markers — returning the dashboard to the
10
+ // `[x]`, and clears the STEP/SEAM markers: returning the dashboard to the
11
11
  // DESIGN boundary.
12
12
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
14
14
  import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearHandoff, clearTick, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
15
15
  import { runCheck } from "../lib/check.js";
16
+ import { readCommitBody } from "../lib/commitbody.js";
16
17
  import { checkLatch } from "../lib/latch.js";
17
18
  import { markStepDone, parseSteps } from "../lib/orient.js";
18
19
  import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
@@ -22,7 +23,7 @@ import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
22
23
  /**
23
24
  * Land a step: latch, check gate, commit, record, return to the boundary.
24
25
  *
25
- * Exit 1 on any refusal — no session, no resolvable step, a latched tick, or a
26
+ * Exit 1 on any refusal: no session, no resolvable step, a latched tick, or a
26
27
  * red/broken check; each refusal says what unblocks it.
27
28
  */
28
29
  export async function checkpoint(cwd, args) {
@@ -31,7 +32,7 @@ export async function checkpoint(cwd, args) {
31
32
  process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
32
33
  return 1;
33
34
  }
34
- // Before either staging path — `-A` below, and the plan commit's folder-scoped
35
+ // Before either staging path: `-A` below, and the plan commit's folder-scoped
35
36
  // add, which also leans on the excludes to skip the in-flight markers inside
36
37
  // the build folder.
37
38
  refreshExcludes(root);
@@ -43,10 +44,18 @@ export async function checkpoint(cwd, args) {
43
44
  process.stderr.write('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
44
45
  return 1;
45
46
  }
46
- // The approval latch runs before the check gate — cheap first. The tick may
47
+ // Checked before the latch and the check gate: a socket stdin can never
48
+ // deliver the requested body, so there is no point latching or running a
49
+ // ~55s check only to discover that afterward.
50
+ const bodyResult = readCommitBody(args);
51
+ if (!bodyResult.ok) {
52
+ process.stderr.write(bodyResult.message);
53
+ return 1;
54
+ }
55
+ // The approval latch runs before the check gate: cheap first. The tick may
47
56
  // not land without a human turn since the step was entered, a one-turn grant
48
57
  // the human typed, or a dormant ledger. A refusal is not an error: the
49
- // message is the pause affordance — exit 1 and hand the turn back.
58
+ // message is the pause affordance: exit 1 and hand the turn back.
50
59
  const latch = checkLatch(root, step);
51
60
  if (!latch.allow) {
52
61
  process.stderr.write(latch.message);
@@ -69,7 +78,7 @@ export async function checkpoint(cwd, args) {
69
78
  if (isDirty(root)) {
70
79
  stageAll(root);
71
80
  warnScopeDrift(root, step);
72
- const body = withMarker(`plumbbob step ${step}`, bodyArg(args) ?? fallbackBody(root, step));
81
+ const body = withMarker(`plumbbob step ${step}`, bodyResult.body ?? fallbackBody(root, step));
73
82
  sha = commit(root, messageArg(args) ?? subjectForStep(root, step), body);
74
83
  }
75
84
  else {
@@ -86,8 +95,8 @@ export async function checkpoint(cwd, args) {
86
95
  syncBuildLogState(root, activeBuild(root), AT_BOUNDARY);
87
96
  rmSync(seamPath(root), { force: true });
88
97
  rmSync(stepPath(root), { force: true });
89
- clearHandoff(root); // the agent-run handoff ledger is step-scoped — clear it with the markers.
90
- clearTick(root); // the step's entry stamp is spent — the next `build <n>` re-stamps.
98
+ clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
99
+ clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
91
100
  process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
92
101
  return 0;
93
102
  }
@@ -97,33 +106,46 @@ export async function checkpoint(cwd, args) {
97
106
  *
98
107
  * Giving the plan its own commit keeps the first step's diff from absorbing the
99
108
  * scaffold, so `git log` reads baseline → plan → steps. No check gate (there is
100
- * no code work to verify yet), no intent flip, no step markers — the plan lives
109
+ * no code work to verify yet), no intent flip, no step markers: the plan lives
101
110
  * entirely in DESIGN. An optional `--body` (stdin heredoc) rides after the
102
111
  * `plumbbob plan` marker line; the folder is whitelisted artifact plane, so
103
112
  * there is no scope drift to warn about.
104
113
  */
105
114
  function checkpointPlan(root, args) {
106
115
  // The latch covers the plan commit too, keyed on the TICK that `start`
107
- // stamped: the plan pause is a real pause. No step number — a step-range
116
+ // stamped: the plan pause is a real pause. No step number: a step-range
108
117
  // grant does not speak to a plan.
109
118
  const latch = checkLatch(root, null);
110
119
  if (!latch.allow) {
111
120
  process.stderr.write(latch.message);
112
121
  return 1;
113
122
  }
114
- stagePath(root, buildFolder(root));
115
- const sha = commit(root, planSubject(root), withMarker('plumbbob plan', bodyArg(args) ?? undefined));
123
+ const bodyResult = readCommitBody(args);
124
+ if (!bodyResult.ok) {
125
+ process.stderr.write(bodyResult.message);
126
+ return 1;
127
+ }
128
+ // A repo that gitignores the sidecar (its `.gitignore`, not plumbbob's own
129
+ // info/exclude) makes this folder-scoped add impossible; stagePath skips it
130
+ // and reports false, and the plan still lands as a valid record-only commit:
131
+ // its body carries the plan, the files stay untracked, and `--allow-empty`
132
+ // (in commit) makes the empty diff legal. History still reads baseline → plan
133
+ // → steps.
134
+ const staged = stagePath(root, buildFolder(root));
135
+ const sha = commit(root, planSubject(root), withMarker('plumbbob plan', bodyResult.body ?? undefined));
116
136
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
117
137
  // Landing the plan consumes `start`'s entry stamp: a later hand-built diff
118
138
  // (no `build <n>`) must find no stale TICK and stay guidance-governed.
119
139
  clearTick(root);
120
- process.stdout.write(`plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
140
+ process.stdout.write(staged
141
+ ? `plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`
142
+ : `plumbbob: plan committed (record-only — sidecar is gitignored; the plan rides the commit message, the files stay untracked) — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
121
143
  return 0;
122
144
  }
123
145
  /**
124
146
  * Compose the plan commit's CLI-owned subject: `chore(<scope>): plan`.
125
147
  *
126
- * The scope resolves through the build-default fallback chain — a bare
148
+ * The scope resolves through the build-default fallback chain: a bare
127
149
  * `chore: plan` when none resolves. The `plumbbob plan` identifier rides the
128
150
  * body marker line, never the subject.
129
151
  */
@@ -134,7 +156,7 @@ function planSubject(root) {
134
156
  * Resolve the step being checkpointed, or null when none can be determined.
135
157
  *
136
158
  * Explicit arg > in-flight STEP file > first undone step in intent.md. A `-m`
137
- * value is a message, never a step — `checkpoint -m "2"` must not read as
159
+ * value is a message, never a step: `checkpoint -m "2"` must not read as
138
160
  * step 2.
139
161
  */
140
162
  function resolveStep(root, args) {
@@ -154,10 +176,10 @@ function resolveStep(root, args) {
154
176
  }
155
177
  }
156
178
  /**
157
- * Warn — never refuse — when the staged tree reaches beyond the step's seam.
179
+ * Warn (never refuse) when the staged tree reaches beyond the step's seam.
158
180
  *
159
181
  * Guidance, not a gate: the checkpoint captures the drift and says so. The seam
160
- * (the step's edit grant — exact paths or `dir/` prefixes) comes from the
182
+ * (the step's edit grant: exact paths or `dir/` prefixes) comes from the
161
183
  * in-flight SEAM file when a build is live, else the step's declared seam in
162
184
  * intent.md. Plumbbob's own artifact plane is whitelisted inside `scopeDrift`,
163
185
  * so the `[x]` flip and build-log line this very checkpoint stages never read
@@ -177,7 +199,7 @@ function warnScopeDrift(root, step) {
177
199
  *
178
200
  * The normalized SEAM file `build` wrote is authoritative while a build is
179
201
  * live; fall back to the step's declared seam parsed from intent.md. Empty when
180
- * neither resolves — the caller then skips the drift warning rather than
202
+ * neither resolves: the caller then skips the drift warning rather than
181
203
  * flagging the whole tree.
182
204
  */
183
205
  function seamTokens(root, step) {
@@ -191,14 +213,14 @@ function seamTokens(root, step) {
191
213
  }
192
214
  }
193
215
  catch {
194
- // no SEAM file — fall through to the declared seam.
216
+ // no SEAM file: fall through to the declared seam.
195
217
  }
196
218
  return seamForStep(root, step);
197
219
  }
198
220
  /**
199
221
  * Flip the step's checkbox in intent.md to `[x]`.
200
222
  *
201
- * Best-effort bookkeeping — the checkpoint SHA is the source of truth — but the
223
+ * Best-effort bookkeeping (the checkpoint SHA is the source of truth) but the
202
224
  * dashboard reads intent.md, so a swallowed failure here would make orientation
203
225
  * lie; the catch warns and asks for a hand flip instead.
204
226
  */
@@ -216,7 +238,7 @@ function flipIntent(root, step) {
216
238
  *
217
239
  * The build's history accrues at each checkpoint instead of being reconstructed
218
240
  * at finish; the step's title is lifted from intent.md when still present.
219
- * Best-effort: a missing or odd build-log never blocks a checkpoint — the
241
+ * Best-effort: a missing or odd build-log never blocks a checkpoint; the
220
242
  * `checkpoints` SHA is the source of truth.
221
243
  */
222
244
  function logCheckpoint(root, step, sha) {
@@ -236,7 +258,7 @@ function logCheckpoint(root, step, sha) {
236
258
  /**
237
259
  * The compact stats receipt riding the Log line, or null when nothing accrued.
238
260
  *
239
- * Only what happened is shown — a clean first-try step gets no suffix at all.
261
+ * Only what happened is shown: a clean first-try step gets no suffix at all.
240
262
  * Wall-clock needs both stamps: a hand-built step never ran `build <n>`, so it
241
263
  * has no startedAt.
242
264
  */
@@ -280,7 +302,7 @@ function subjectForStep(root, step) {
280
302
  * The build-default Conventional scope, or null for a bare subject.
281
303
  *
282
304
  * The `**Scope:**` header field in intent.md wins when authored and filled;
283
- * else the build slug with its date prefix stripped; else null — a build with
305
+ * else the build slug with its date prefix stripped; else null: a build with
284
306
  * neither field keeps producing bare subjects.
285
307
  */
286
308
  function buildDefaultScope(root) {
@@ -291,7 +313,7 @@ function buildDefaultScope(root) {
291
313
  }
292
314
  }
293
315
  catch {
294
- // no intent.md yet — fall through to the slug rung.
316
+ // no intent.md yet: fall through to the slug rung.
295
317
  }
296
318
  return buildScope(activeBuild(root));
297
319
  }
@@ -313,27 +335,6 @@ function messageArg(args) {
313
335
  const i = args.indexOf('-m');
314
336
  return i !== -1 && i + 1 < args.length ? (args[i + 1] ?? null) : null;
315
337
  }
316
- /**
317
- * Read the `--body` commit body from stdin, or null when absent or empty.
318
- *
319
- * The stdin heredoc lets a skill compose proportional prose the CLI never
320
- * could; null either way hands over to the deterministic fallback body. Reading
321
- * fd 0 blocks until EOF, which the heredoc supplies; a read error (no stdin
322
- * attached) degrades to the fallback, and an interactive TTY — which would
323
- * never send EOF — skips the read instead of hanging.
324
- */
325
- function bodyArg(args) {
326
- if (!args.includes('--body') || process.stdin.isTTY === true) {
327
- return null;
328
- }
329
- try {
330
- const raw = readFileSync(0, 'utf8').trimEnd();
331
- return raw.length > 0 ? raw : null;
332
- }
333
- catch {
334
- return null;
335
- }
336
- }
337
338
  /**
338
339
  * The deterministic fallback commit body: the step's done-when, its seam, and
339
340
  * the staged diffstat.
@@ -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,14 +245,14 @@ 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
255
+ * STAGES the result but never commits: the human owns that commit. Call only when
256
256
  * `inspectLegacy` reported a legacy layout; doctor prints the returned actions.
257
257
  */
258
258
  export function migrateSidecar(root) {
@@ -263,7 +263,7 @@ export function migrateSidecar(root) {
263
263
  const configPath = join(dir, 'config');
264
264
  if (existsSync(configPath)) {
265
265
  if (!existsSync(settingsPath(root))) {
266
- // Carry forward ONLY what the legacy config actually held — a `check` line,
266
+ // Carry forward ONLY what the legacy config actually held: a `check` line,
267
267
  // nothing else. No invented `auto` (the config never had one, it defaults to
268
268
  // false, and it belongs in settings.local.json): migration yields exactly what
269
269
  // a fresh `start` would, plus the check. An empty config → an empty `{}`.
@@ -336,7 +336,7 @@ function sidecarReport(cwd, migrate) {
336
336
  /**
337
337
  * Interpreters recognized as the program of a manifest `command`: when the command
338
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
339
+ * argument (the interpreter reads it: the file need not carry +x), not the
340
340
  * interpreter, which we trust to be on PATH.
341
341
  */
342
342
  const INTERPRETERS = new Set(['sh', 'bash', 'zsh', 'dash', 'node', 'deno', 'bun', 'python', 'python3', 'ruby', 'perl']);
@@ -344,7 +344,7 @@ const INTERPRETERS = new Set(['sh', 'bash', 'zsh', 'dash', 'node', 'deno', 'bun'
344
344
  * Expand PLUMBBOB_AGENT_DIR in a command and split it into bare tokens
345
345
  * (surrounding quotes stripped).
346
346
  *
347
- * A deliberately shallow split — enough for the static command check below, not a
347
+ * A deliberately shallow split: enough for the static command check below, not a
348
348
  * shell parser (which is neither possible nor wanted for a best-effort diagnostic).
349
349
  */
350
350
  function commandTokens(command, agentDir) {
@@ -356,11 +356,11 @@ function commandTokens(command, agentDir) {
356
356
  }
357
357
  /**
358
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.
359
+ * does not exist, or a directly invoked one missing its +x bit.
360
360
  *
361
361
  * The command is a shell string spawned via `sh -c` with the repo root as cwd, so a
362
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
363
+ * own dir. Best-effort by design: a path-shaped program or an interpreter's script
364
364
  * argument is checked; a bare command (a PATH binary, an inline `node -e` program)
365
365
  * is trusted, because we cannot prove a shell line broken without running it.
366
366
  * Returns the problem string, or null when nothing checkable is wrong.
@@ -443,7 +443,7 @@ function agentReport(cwd) {
443
443
  *
444
444
  * A configured `check` setting names the spawn override and asks nothing more;
445
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")
446
+ * checkride marks `required` count as problems; an empty slot ("no tool detected")
447
447
  * is informational, not a failure, because the runtime gate already refuses a
448
448
  * vacuous run.
449
449
  */
@@ -474,7 +474,7 @@ async function gateReport(cwd, env) {
474
474
  failed += 1;
475
475
  }
476
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
477
+ // checkpoints would be gated only by checkride's always-on repo checks: either
478
478
  // a vacuous refusal or, worse, a green that tested nothing. Informational, with
479
479
  // the exact fix; `start` surfaces the same probe at plan time.
480
480
  if (!gateDetectsTools(report.checks)) {
@@ -490,7 +490,8 @@ async function gateReport(cwd, env) {
490
490
  }
491
491
  /**
492
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.
493
+ * hint, or `○ spell` joined by an em dash to "no tool detected" for an empty
494
+ * (skipping) slot.
494
495
  */
495
496
  function toolRow(c) {
496
497
  if (c.adapter === null || c.adapter === undefined) {
@@ -507,8 +508,8 @@ function toolRow(c) {
507
508
  * The latch is `checkpoint`'s refusal to land a step until the harness has recorded
508
509
  * a human turn since the step was entered. `.plumbbob/TURN` holds a count once the
509
510
  * 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
511
+ * is armed; its absence means the tick is guidance-only. Dormant is a legitimate
512
+ * state (a host with no hooks simply has no ledger), never a failure, so this
512
513
  * section adds nothing to the problem count. The dormant hint names the hook because
513
514
  * a missing ledger almost always means it never wired, and that hook rides in either
514
515
  * install kind (the marketplace plugin's hooks/hooks.json or an init-style link).
@@ -525,9 +526,9 @@ function latchReport(cwd) {
525
526
  : ` ✓ latch: live (turn ${turn})`;
526
527
  const lines = ['', 'plumbbob doctor — approval latch — D64 (approval-latch)', line];
527
528
  // 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
+ // the model can forge is no grant: self-approval comes only from the human's
529
530
  // typed `/build --auto`. Surface a set value so a human relying on it isn't
530
- // silently changed — informational, never a problem.
531
+ // silently changed: informational, never a problem.
531
532
  if (resolveBoolean(root, 'auto', false)) {
532
533
  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
534
  }
@@ -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
  */