plumbbob 0.8.17 → 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 +56 -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 +87 -44
  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 +2 -2
  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 +18 -5
  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,12 +1,14 @@
1
- // `plumbbob checkpoint [<n>] [-m <msg>]` — the executor-agnostic commit tick (D3).
2
- // It does NOT require a STEP file: the step is whatever you pass,
3
- // else the in-flight STEP, else the next undone step in intent. It gates on a green
4
- // check, then commits any pending work (or records the existing HEAD when the tree
5
- // is already clean — the human's commit skill may have committed first), records the
6
- // SHA, flips the intent checkbox to `[x]`, and clears any STEP/SEAM (which returns
7
- // the dashboard to the DESIGN boundary). The diff's author is irrelevant:
8
- // `/pb-build`, your hands, a vibe session, or another harness all checkpoint
9
- // the same way.
1
+ // `plumbbob checkpoint [<n>] [-m <msg>]` — the commit tick that lands a step.
2
+ // Executor-agnostic and author-blind: it reads the diff, not who wrote it, so a
3
+ // `/plumbbob:build` run, your own hands, a vibe session, or another harness all
4
+ // checkpoint identically. It does NOT require a STEP marker (the flat control
5
+ // file recording the step in flight): the step is whatever you pass, else the
6
+ // in-flight STEP, else the next undone step in intent.md. It evaluates the
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
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
11
+ // DESIGN boundary.
10
12
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
13
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
12
14
  import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearHandoff, clearTick, hasSession, intentPath, readStats, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
@@ -17,6 +19,12 @@ import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
17
19
  import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
18
20
  import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
19
21
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
22
+ /**
23
+ * Land a step: latch, check gate, commit, record, return to the boundary.
24
+ *
25
+ * Exit 1 on any refusal — no session, no resolvable step, a latched tick, or a
26
+ * red/broken check; each refusal says what unblocks it.
27
+ */
20
28
  export async function checkpoint(cwd, args) {
21
29
  const root = findRepoRoot(cwd);
22
30
  if (root === null || !hasSession(root)) {
@@ -31,8 +39,9 @@ export async function checkpoint(cwd, args) {
31
39
  process.stderr.write('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
32
40
  return 1;
33
41
  }
34
- // The approval latch (D64) precedes the check gate (C4): the tick may not land
35
- // without a human turn since entry, a grant, or a dormant ledger. The refusal
42
+ // The approval latch runs before the check gate — cheap first. The tick may
43
+ // not land without a human turn since the step was entered, a one-turn grant
44
+ // the human typed, or a dormant ledger. A refusal is not an error: the
36
45
  // message is the pause affordance — exit 1 and hand the turn back.
37
46
  const latch = checkLatch(root, step);
38
47
  if (!latch.allow) {
@@ -42,8 +51,9 @@ export async function checkpoint(cwd, args) {
42
51
  const gate = await runCheck(root);
43
52
  if (gate !== 0) {
44
53
  if (gate === 1) {
45
- // The dogfood receipt (research/07 Build 2b): red attempts before green
46
- // are a per-step count. Harness breakage (2) is not a red check.
54
+ // Red attempts before green accrue as a per-step stat, surfaced in the
55
+ // build-log receipt and the finish report. Harness breakage (exit 2) is
56
+ // not a red check.
47
57
  bumpStepStat(root, undefined, step, 'redChecks');
48
58
  }
49
59
  process.stderr.write(gate === 2
@@ -65,28 +75,33 @@ export async function checkpoint(cwd, args) {
65
75
  flipIntent(root, step);
66
76
  stampStepStat(root, undefined, step, 'landedAt', new Date().toISOString());
67
77
  logCheckpoint(root, step, sha);
68
- // The step landed: the build-log's mirror flips to ☑ and Current step returns to
69
- // the boundary (D69), re-read from the intent.md `flipIntent` just wrote. The
70
- // artifact-plane whitelist keeps this build-log write from ever reading as drift.
78
+ // The step landed: the build-log's ☐/☑ mirror flips and its Current-step line
79
+ // returns to the boundary, re-read from the intent.md `flipIntent` just wrote.
80
+ // The artifact-plane whitelist keeps this build-log write from ever reading as
81
+ // scope drift.
71
82
  syncBuildLogState(root, activeBuild(root), AT_BOUNDARY);
72
83
  rmSync(seamPath(root), { force: true });
73
84
  rmSync(stepPath(root), { force: true });
74
- clearHandoff(root); // the agent-run ledger is step-scoped (D47) — clear it with the markers.
75
- clearTick(root); // the entry stamp (D64) is spent — the next `build <n>` re-stamps.
85
+ clearHandoff(root); // the agent-run handoff ledger is step-scoped — clear it with the markers.
86
+ clearTick(root); // the step's entry stamp is spent — the next `build <n>` re-stamps.
76
87
  process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
77
88
  return 0;
78
89
  }
79
- // The plan-approval commit (D36): stage only the active build's artifact folder and
80
- // commit it as `chore(<scope>): plan` (D68), then record a `plan <sha>` line. Giving
81
- // the plan its own commit keeps the first step's diff from absorbing the scaffold, so
82
- // `git log` reads baseline → plan → steps. No check gate (there is no code work to
83
- // verify yet), no intent flip, no step markers — the plan lives entirely in DESIGN.
84
- // An optional `--body` (stdin heredoc, D68) rides after the `plumbbob plan` marker;
85
- // the folder is whitelisted artifact plane, so there is no scope-drift to warn about.
90
+ /**
91
+ * The plan-approval commit: stage only the build's artifact folder, commit it as
92
+ * `chore(<scope>): plan`, and record a `plan <sha>` line.
93
+ *
94
+ * Giving the plan its own commit keeps the first step's diff from absorbing the
95
+ * scaffold, so `git log` reads baseline → plan → steps. No check gate (there is
96
+ * no code work to verify yet), no intent flip, no step markers — the plan lives
97
+ * entirely in DESIGN. An optional `--body` (stdin heredoc) rides after the
98
+ * `plumbbob plan` marker line; the folder is whitelisted artifact plane, so
99
+ * there is no scope drift to warn about.
100
+ */
86
101
  function checkpointPlan(root, args) {
87
- // The latch (D64) covers the plan commit too, keyed on the TICK that `start`
88
- // stamped: the plan pause is a pause. No step number — a range grant does not
89
- // speak to a plan.
102
+ // The latch covers the plan commit too, keyed on the TICK that `start`
103
+ // stamped: the plan pause is a real pause. No step number — a step-range
104
+ // grant does not speak to a plan.
90
105
  const latch = checkLatch(root, null);
91
106
  if (!latch.allow) {
92
107
  process.stderr.write(latch.message);
@@ -95,22 +110,29 @@ function checkpointPlan(root, args) {
95
110
  stagePath(root, buildFolder(root));
96
111
  const sha = commit(root, planSubject(root), withMarker('plumbbob plan', bodyArg(args) ?? undefined));
97
112
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
98
- // The plan landing consumes `start`'s entry stamp (D64): a later hand-built diff
113
+ // Landing the plan consumes `start`'s entry stamp: a later hand-built diff
99
114
  // (no `build <n>`) must find no stale TICK and stay guidance-governed.
100
115
  clearTick(root);
101
116
  process.stdout.write(`plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
102
117
  return 0;
103
118
  }
104
- // The plan commit's CLI-owned subject (D68): `chore(<scope>): plan`, the scope
105
- // resolved through the build-default fallback chain (a bare `chore: plan` when
106
- // none resolves). The `plumbbob plan` identifier rides the body marker, not the
107
- // subject.
119
+ /**
120
+ * Compose the plan commit's CLI-owned subject: `chore(<scope>): plan`.
121
+ *
122
+ * The scope resolves through the build-default fallback chain — a bare
123
+ * `chore: plan` when none resolves. The `plumbbob plan` identifier rides the
124
+ * body marker line, never the subject.
125
+ */
108
126
  function planSubject(root) {
109
127
  return conventionalSubject({ type: 'chore', scope: buildDefaultScope(root), description: 'plan' });
110
128
  }
111
- // Step resolution (D3): explicit arg > in-flight STEP file > first undone step in
112
- // intent.md. Returns null when none can be determined. A `-m` value is a message,
113
- // never a step — `checkpoint -m "2"` must not read as step 2.
129
+ /**
130
+ * Resolve the step being checkpointed, or null when none can be determined.
131
+ *
132
+ * Explicit arg > in-flight STEP file > first undone step in intent.md. A `-m`
133
+ * value is a message, never a step — `checkpoint -m "2"` must not read as
134
+ * step 2.
135
+ */
114
136
  function resolveStep(root, args) {
115
137
  const explicit = args.filter((_, i) => args[i - 1] !== '-m').find((a) => /^\d+$/.test(a));
116
138
  if (explicit !== undefined) {
@@ -127,24 +149,33 @@ function resolveStep(root, args) {
127
149
  return null;
128
150
  }
129
151
  }
130
- // Guidance, not a gate (the enforce→guide pivot): warn when the staged tree reaches
131
- // beyond the step's seam, then commit anyway. The seam comes from the in-flight SEAM
132
- // file when a build is live, else the step's declared seam in intent.md. Plumbbob's
133
- // own artifact plane is whitelisted (`scopeDrift`), so the `[x]` flip and build-log
134
- // line this very checkpoint stages never read as drift. No seam ⇒ no warning.
152
+ /**
153
+ * Warn — never refuse — when the staged tree reaches beyond the step's seam.
154
+ *
155
+ * Guidance, not a gate: the checkpoint captures the drift and says so. The seam
156
+ * (the step's edit grant — exact paths or `dir/` prefixes) comes from the
157
+ * in-flight SEAM file when a build is live, else the step's declared seam in
158
+ * intent.md. Plumbbob's own artifact plane is whitelisted inside `scopeDrift`,
159
+ * so the `[x]` flip and build-log line this very checkpoint stages never read
160
+ * as drift. No seam means no warning.
161
+ */
135
162
  function warnScopeDrift(root, step) {
136
163
  const seam = seamTokens(root, step);
137
164
  const outside = scopeDrift(stagedPaths(root), seam);
138
165
  if (outside.length > 0) {
139
- bumpStepStat(root, undefined, step, 'driftWarnings'); // the dogfood receipt (research/07 2b)
166
+ bumpStepStat(root, undefined, step, 'driftWarnings'); // accrues into the build-log's stats receipt
140
167
  process.stderr.write(`plumbbob: heads-up — staged paths outside step ${step}'s seam: ${outside.join(', ')}. ` +
141
- `The checkpoint captures them; if that's real scope drift, the plan may need a \`/pb-step\` revision.\n`);
168
+ `The checkpoint captures them; if that's real scope drift, the plan may need a \`/plumbbob:step\` revision.\n`);
142
169
  }
143
170
  }
144
- // The seam tokens for the in-flight step: the normalized SEAM file `build` wrote
145
- // (authoritative while a build is live), falling back to the step's declared seam
146
- // parsed from intent.md. Empty when neither resolves — the caller then skips the
147
- // warning rather than flagging the whole tree.
171
+ /**
172
+ * The seam tokens governing the in-flight step.
173
+ *
174
+ * The normalized SEAM file `build` wrote is authoritative while a build is
175
+ * live; fall back to the step's declared seam parsed from intent.md. Empty when
176
+ * neither resolves — the caller then skips the drift warning rather than
177
+ * flagging the whole tree.
178
+ */
148
179
  function seamTokens(root, step) {
149
180
  try {
150
181
  const fromFile = readFileSync(seamPath(root), 'utf8')
@@ -160,21 +191,30 @@ function seamTokens(root, step) {
160
191
  }
161
192
  return seamForStep(root, step);
162
193
  }
194
+ /**
195
+ * Flip the step's checkbox in intent.md to `[x]`.
196
+ *
197
+ * Best-effort bookkeeping — the checkpoint SHA is the source of truth — but the
198
+ * dashboard reads intent.md, so a swallowed failure here would make orientation
199
+ * lie; the catch warns and asks for a hand flip instead.
200
+ */
163
201
  function flipIntent(root, step) {
164
202
  try {
165
203
  writeFileSync(intentPath(root), markStepDone(readFileSync(intentPath(root), 'utf8'), step));
166
204
  }
167
205
  catch {
168
- // Best-effort bookkeeping — the checkpoint SHA is the source of truth — but the
169
- // dashboard reads intent.md, so a swallowed failure here makes orientation lie.
170
206
  process.stderr.write(`plumbbob: heads-up — could not flip step ${step} to [x] in intent.md; ` +
171
207
  `the checkpoint is recorded, but the dashboard will still show step ${step} as next. Flip it by hand.\n`);
172
208
  }
173
209
  }
174
- // Append a dated line to the build-log's `## Log` so the build's history accrues at
175
- // each checkpoint instead of being reconstructed at finish. The step's title is lifted
176
- // from intent.md when still present. Best-effort: a missing/odd build-log never blocks
177
- // a checkpoint — the `checkpoints` SHA is the source of truth.
210
+ /**
211
+ * Append a dated line to the build-log's `## Log` section.
212
+ *
213
+ * The build's history accrues at each checkpoint instead of being reconstructed
214
+ * at finish; the step's title is lifted from intent.md when still present.
215
+ * Best-effort: a missing or odd build-log never blocks a checkpoint — the
216
+ * `checkpoints` SHA is the source of truth.
217
+ */
178
218
  function logCheckpoint(root, step, sha) {
179
219
  try {
180
220
  const path = buildLogPath(root);
@@ -189,9 +229,13 @@ function logCheckpoint(root, step, sha) {
189
229
  // best-effort ledger; never fail a checkpoint over the build-log.
190
230
  }
191
231
  }
192
- // The compact receipt riding the Log line (research/07 Build 2b): only what
193
- // accrued — a clean first-try step gets no suffix at all. Wall-clock needs both
194
- // stamps (a hand-built step never ran `build <n>`, so it has no startedAt).
232
+ /**
233
+ * The compact stats receipt riding the Log line, or null when nothing accrued.
234
+ *
235
+ * Only what happened is shown — a clean first-try step gets no suffix at all.
236
+ * Wall-clock needs both stamps: a hand-built step never ran `build <n>`, so it
237
+ * has no startedAt.
238
+ */
195
239
  function statsSuffix(root, step) {
196
240
  const stats = readStats(root)[String(step)];
197
241
  if (stats === undefined)
@@ -210,14 +254,17 @@ function statsSuffix(root, step) {
210
254
  }
211
255
  return parts.length > 0 ? parts.join(', ') : null;
212
256
  }
213
- // The CLI-owned, deterministic commit subject (D68): a Conventional `type(scope):
214
- // description`. The type and wording come from the step's title — an author-written
215
- // prefix (`fix(parser): …`) is honoured verbatim, a bare title defaults to `feat`.
216
- // The scope falls back through the chain (D3): the title's own `(scope)` wins
217
- // (handled inside `subjectFromTitle`), else `buildDefaultScope`. A titleless step
218
- // falls back to `chore(<scope>): checkpoint`. The `plumbbob`/`step N` identifiers
219
- // ride the body marker, not the subject; a `-m` override or `--body` prose is a
220
- // separate concern.
257
+ /**
258
+ * Compose the step's CLI-owned, deterministic Conventional Commit subject.
259
+ *
260
+ * The type and wording come from the step's title: an author-written prefix
261
+ * (`fix(parser): …`) is honoured verbatim, a bare prose title defaults to
262
+ * `feat`. The title's own `(scope)` wins (handled inside `subjectFromTitle`),
263
+ * else the build-default scope. A titleless step falls back to
264
+ * `chore(<scope>): checkpoint`. The `plumbbob`/`step N` identifiers ride the
265
+ * body marker line, not the subject; a `-m` override or `--body` prose is a
266
+ * separate concern.
267
+ */
221
268
  function subjectForStep(root, step) {
222
269
  const scope = buildDefaultScope(root);
223
270
  const title = titleForStep(root, step);
@@ -225,10 +272,13 @@ function subjectForStep(root, step) {
225
272
  ? subjectFromTitle(title, 'feat', scope)
226
273
  : conventionalSubject({ type: 'chore', scope, description: 'checkpoint' });
227
274
  }
228
- // The build-default Conventional scope (D3/D4): the `**Scope:**` header field in
229
- // intent.md when authored and filled, else the build slug (D68's original rung,
230
- // now the penultimate one), else null — a bare subject (C2 back-compat: a build
231
- // with neither field behaves exactly as before D4).
275
+ /**
276
+ * The build-default Conventional scope, or null for a bare subject.
277
+ *
278
+ * The `**Scope:**` header field in intent.md wins when authored and filled;
279
+ * else the build slug with its date prefix stripped; else null — a build with
280
+ * neither field keeps producing bare subjects.
281
+ */
232
282
  function buildDefaultScope(root) {
233
283
  try {
234
284
  const fromHeader = parseBuildScope(readFileSync(intentPath(root), 'utf8'));
@@ -241,6 +291,9 @@ function buildDefaultScope(root) {
241
291
  }
242
292
  return buildScope(activeBuild(root));
243
293
  }
294
+ /**
295
+ * The step's title from intent.md, or null when absent or unreadable.
296
+ */
244
297
  function titleForStep(root, step) {
245
298
  try {
246
299
  return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => s.n === step)?.title ?? null;
@@ -249,16 +302,22 @@ function titleForStep(root, step) {
249
302
  return null;
250
303
  }
251
304
  }
305
+ /**
306
+ * The `-m <msg>` subject override, or null when the flag is absent.
307
+ */
252
308
  function messageArg(args) {
253
309
  const i = args.indexOf('-m');
254
310
  return i !== -1 && i + 1 < args.length ? (args[i + 1] ?? null) : null;
255
311
  }
256
- // `--body` reads the commit body from stdin (the single-quoted heredoc of D34),
257
- // so the skill can compose proportional prose the CLI never could. Returns null
258
- // when the flag is absent or stdin is empty — either way the deterministic
259
- // fallback body takes over. Reading fd 0 blocks until EOF, which the heredoc
260
- // supplies; a read error (no stdin attached) degrades to the fallback, and an
261
- // interactive TTY — which would never send EOF — skips the read instead of hanging.
312
+ /**
313
+ * Read the `--body` commit body from stdin, or null when absent or empty.
314
+ *
315
+ * The stdin heredoc lets a skill compose proportional prose the CLI never
316
+ * could; null either way hands over to the deterministic fallback body. Reading
317
+ * fd 0 blocks until EOF, which the heredoc supplies; a read error (no stdin
318
+ * attached) degrades to the fallback, and an interactive TTY — which would
319
+ * never send EOF — skips the read instead of hanging.
320
+ */
262
321
  function bodyArg(args) {
263
322
  if (!args.includes('--body') || process.stdin.isTTY === true) {
264
323
  return null;
@@ -271,10 +330,14 @@ function bodyArg(args) {
271
330
  return null;
272
331
  }
273
332
  }
274
- // The deterministic checkpoint body (D35): the step's done-when, its seam, and the
275
- // staged diffstat — so a hand-built or vibed checkpoint still gets informative
276
- // history without a model turn. Each part is best-effort; a missing piece is
277
- // simply omitted, and an empty result leaves the commit body blank.
333
+ /**
334
+ * The deterministic fallback commit body: the step's done-when, its seam, and
335
+ * the staged diffstat.
336
+ *
337
+ * A hand-built or vibed checkpoint still gets informative history without a
338
+ * model turn. Each part is best-effort; a missing piece is simply omitted, and
339
+ * an empty result leaves the commit body blank.
340
+ */
278
341
  function fallbackBody(root, step) {
279
342
  const parts = [];
280
343
  const doneWhen = doneWhenForStep(root, step);
@@ -291,6 +354,9 @@ function fallbackBody(root, step) {
291
354
  }
292
355
  return parts.length > 0 ? parts.join('\n\n') : undefined;
293
356
  }
357
+ /**
358
+ * The step's done-when line from intent.md, or null when absent or unreadable.
359
+ */
294
360
  function doneWhenForStep(root, step) {
295
361
  try {
296
362
  return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => s.n === step)?.doneWhen ?? null;
@@ -299,6 +365,9 @@ function doneWhenForStep(root, step) {
299
365
  return null;
300
366
  }
301
367
  }
368
+ /**
369
+ * The step's declared seam from intent.md, or empty when missing or malformed.
370
+ */
302
371
  function seamForStep(root, step) {
303
372
  try {
304
373
  const parsed = parseStepSeam(readFileSync(intentPath(root), 'utf8'), step);
@@ -308,6 +377,9 @@ function seamForStep(root, step) {
308
377
  return [];
309
378
  }
310
379
  }
380
+ /**
381
+ * The staged diffstat, or '' when git refuses.
382
+ */
311
383
  function safeStagedStat(root) {
312
384
  try {
313
385
  return stagedStat(root);
@@ -316,6 +388,9 @@ function safeStagedStat(root) {
316
388
  return '';
317
389
  }
318
390
  }
391
+ /**
392
+ * The in-flight step number from the STEP file, or null when absent or garbled.
393
+ */
319
394
  function readStep(root) {
320
395
  try {
321
396
  const raw = readFileSync(stepPath(root), 'utf8').trim();