plumbbob 0.8.18 → 0.9.2

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 +51 -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 +356 -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 +158 -79
  21. package/dist/verbs/doctor.js +192 -95
  22. package/dist/verbs/finish.js +88 -46
  23. package/dist/verbs/handoff.js +56 -20
  24. package/dist/verbs/init.js +37 -12
  25. package/dist/verbs/park.js +14 -3
  26. package/dist/verbs/revert.js +64 -24
  27. package/dist/verbs/spike.js +89 -30
  28. package/dist/verbs/start.js +72 -38
  29. package/dist/verbs/status.js +44 -17
  30. package/dist/verbs/turn.js +88 -45
  31. package/dist/verbs/use.js +20 -9
  32. package/package.json +1 -1
  33. package/skills/{pb-build → build}/SKILL.md +25 -25
  34. package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
  35. package/skills/{pb-finish → finish}/SKILL.md +3 -3
  36. package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
  37. package/skills/{pb-park → park}/SKILL.md +2 -2
  38. package/skills/{pb-plan → plan}/SKILL.md +11 -11
  39. package/skills/{pb-refine → refine}/SKILL.md +7 -7
  40. package/skills/{pb-revert → revert}/SKILL.md +2 -2
  41. package/skills/{pb-spike → spike}/SKILL.md +4 -4
  42. package/skills/{pb-status → status}/SKILL.md +3 -3
  43. package/skills/{pb-step → step}/SKILL.md +9 -9
  44. package/skills/{pb-verify → verify}/SKILL.md +8 -8
  45. package/templates/build-log.md +7 -7
  46. package/templates/intent.md +8 -8
  47. package/templates/spike-report.md +1 -1
@@ -1,50 +1,73 @@
1
- // The .plumbbob/ sidecar: control state lives in flat files so the hooks can
2
- // read it with a grep and no markdown parsing (D7). Functional/procedural,
3
- // node builtins only (C1/C2).
1
+ // The .plumbbob/ sidecar — plumbbob's control directory beside the code. Control
2
+ // state lives in flat files (bare numbers, plain path lists, one-line markers) so
3
+ // shell hooks can read it with a grep and no markdown parsing. Functional and
4
+ // procedural, node builtins only.
4
5
  //
5
- // STATE is the session sentinel AND the active-build cursor (D28): its EXISTENCE
6
- // means "a session is active", and its CONTENT names the build that session is on —
7
- // the two reinforce each other (a session is always on some build; the cursor is
6
+ // Two planes share the directory: the tracked ARTIFACT plane (settings.json and
7
+ // each build's `builds/<slug>/` folder — intent, build-log, checkpoints, report),
8
+ // which rides its branch into the PR, and the git-excluded CONTROL plane (STATE,
9
+ // the personal settings overlay, the turn ledger, and each build's in-flight
10
+ // markers), which stays per-worktree and never commits.
11
+ //
12
+ // STATE is the session sentinel AND the active-build cursor: its EXISTENCE means
13
+ // "a session is active", and its CONTENT names the build that session is on — the
14
+ // two reinforce each other (a session is always on some build; the cursor is
8
15
  // meaningless without a session). Content is empty under --local / no build.
9
- // hasSession stays existence-only; activeBuild reads the content. Homing the cursor
10
- // here — not in settings.local.json — keeps that overlay purely human-owned
11
- // (check/auto): the tool only ever reads it, never rewrites it. The phase the
12
- // dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored — BUILD ⇔ a STEP is
13
- // in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
16
+ // hasSession stays existence-only; activeBuild reads the content. Homing the
17
+ // cursor here — not in settings.local.json — keeps that overlay purely
18
+ // human-owned (check/auto): the tool only ever reads it, never rewrites it. The
19
+ // phase the dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored — BUILD ⇔
20
+ // a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
14
21
  import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
15
22
  import { dirname, join } from 'node:path';
16
23
  import { gitPath } from "./git.js";
17
- const DIRNAME = '.plumbbob';
24
+ const DIRNAME = '.plumbbob'; // the sidecar folder name at the repo root
25
+ /**
26
+ * The sidecar root: `<root>/.plumbbob`.
27
+ */
18
28
  export function sidecarDir(root) {
19
29
  return join(root, DIRNAME);
20
30
  }
21
- // The tracked artifact plane (D26): each build owns a self-contained folder under
22
- // `.plumbbob/builds/<slug>/` that rides its branch into the PR. `buildDir` is the
23
- // per-build root; intent.md, build-log.md, checkpoints, and report.md live inside
24
- // it (the in-flight STEP/SEAM/SPIKE markers do too, but stay git-excluded).
31
+ /**
32
+ * The tracked artifact plane's root: `.plumbbob/builds/`, one folder per build.
33
+ *
34
+ * Each build owns a self-contained `builds/<slug>/` folder that rides its branch
35
+ * into the PR; intent.md, build-log.md, checkpoints, and report.md live inside it
36
+ * (the in-flight STEP/SEAM/SPIKE markers do too, but stay git-excluded).
37
+ */
25
38
  function buildsDir(root) {
26
39
  return join(root, DIRNAME, 'builds');
27
40
  }
41
+ /**
42
+ * One build's folder: `.plumbbob/builds/<slug>`.
43
+ */
28
44
  export function buildDir(root, slug) {
29
45
  return join(buildsDir(root), slug);
30
46
  }
31
- // Derive a filesystem-safe slug from a build title: lowercased, every run of
32
- // non-alphanumerics collapsed to a single hyphen, trimmed of leading/trailing
33
- // hyphens. The CLI stays dumb and explicit (D38) — collision handling belongs to
34
- // the caller (`start` refuses rather than silently suffixing `-2`, and prepends
35
- // the `YYYY-MM-DD-` date that keeps `listBuilds`' lexical sort chronological). A
36
- // title with no alphanumerics yields `''`, which the caller must reject or override.
47
+ /**
48
+ * Derive a filesystem-safe slug from a build title.
49
+ *
50
+ * Lowercased, every run of non-alphanumerics collapsed to a single hyphen,
51
+ * trimmed of leading/trailing hyphens. The CLI stays dumb and explicit —
52
+ * collision handling belongs to the caller (`start` refuses an existing slug
53
+ * rather than silently suffixing `-2`, and prepends the `YYYY-MM-DD-` date that
54
+ * keeps `listBuilds`' lexical sort chronological). A title with no alphanumerics
55
+ * yields `''`, which the caller must reject or override.
56
+ */
37
57
  export function slugify(title) {
38
58
  return title
39
59
  .toLowerCase()
40
60
  .replace(/[^a-z0-9]+/g, '-')
41
61
  .replace(/^-+|-+$/g, '');
42
62
  }
43
- // The Conventional-Commit scope for a build (D68): its slug with the `YYYY-MM-DD-`
44
- // date prefix `start` prepends stripped off, so `2026-07-18-escape-hatch` yields the
45
- // human-meaningful `escape-hatch`. Null when no build resolves (`--local`, or a slug
46
- // that is nothing but a date) — the caller then omits the `(scope)` segment, which
47
- // Conventional Commits permit.
63
+ /**
64
+ * The Conventional-Commit scope for a build: its slug with the `YYYY-MM-DD-`
65
+ * date prefix `start` prepends stripped off.
66
+ *
67
+ * `2026-07-18-escape-hatch` yields the human-meaningful `escape-hatch`. Null
68
+ * when no build resolves (`--local`, or a slug that is nothing but a date) — the
69
+ * caller then omits the `(scope)` segment, which Conventional Commits permit.
70
+ */
48
71
  export function buildScope(slug) {
49
72
  if (slug === null) {
50
73
  return null;
@@ -52,8 +75,12 @@ export function buildScope(slug) {
52
75
  const stripped = slug.replace(/^\d{4}-\d{2}-\d{2}-/, '');
53
76
  return stripped.length > 0 ? stripped : null;
54
77
  }
55
- // Existing build slugs — the directory names under `builds/`, sorted. Empty when
56
- // `builds/` is absent (a `--local` repo, or before the first tracked `start`).
78
+ /**
79
+ * Existing build slugs — the directory names under `builds/`, sorted.
80
+ *
81
+ * Empty when `builds/` is absent (a `--local` repo, or before the first tracked
82
+ * `start`).
83
+ */
57
84
  export function listBuilds(root) {
58
85
  try {
59
86
  return readdirSync(buildsDir(root), { withFileTypes: true })
@@ -65,10 +92,14 @@ export function listBuilds(root) {
65
92
  return [];
66
93
  }
67
94
  }
68
- // Resolve which build a verb acts on (D28): an explicit `--build <slug>` flag →
69
- // the cursor in STATE → the sole build in `builds/` → null (the caller then refuses
70
- // with a hint). One-active-per-worktree holds by construction: the cursor is STATE's
71
- // single-line content, so it can never point at two builds (D28).
95
+ /**
96
+ * Resolve which build a verb acts on: an explicit `--build <slug>` flag → the
97
+ * cursor in STATE → the sole build in `builds/` → null (the caller then refuses
98
+ * with a hint).
99
+ *
100
+ * One-active-per-worktree holds by construction: the cursor is STATE's
101
+ * single-line content, so it can never point at two builds.
102
+ */
72
103
  export function activeBuild(root, flag) {
73
104
  if (flag !== undefined && flag.length > 0)
74
105
  return flag;
@@ -78,13 +109,16 @@ export function activeBuild(root, flag) {
78
109
  const builds = listBuilds(root);
79
110
  return builds.length === 1 ? (builds[0] ?? null) : null;
80
111
  }
81
- // STATE's content is the active-build cursor. Empty means "no build" (--local, or a
82
- // session whose cursor is unset) → null, so activeBuild falls through to the
83
- // sole-build rule. A pre-STATE-cursor session wrote the literal `active` sentinel
84
- // here (the cursor lived in settings.local.json then); treat that legacy value as
85
- // unset so an in-flight upgrade degrades gracefully instead of chasing a build named
86
- // "active". `active` is thus reserved and never a valid cursor — no derived slug
87
- // collides (they carry a YYYY-MM-DD- prefix).
112
+ /**
113
+ * STATE's content — the active-build cursor — or null when it names no build.
114
+ *
115
+ * Empty means "no build" (--local, or a session whose cursor is unset), so
116
+ * activeBuild falls through to the sole-build rule. The literal value `active`
117
+ * is reserved and reads as unset: a legacy STATE layout wrote it as a bare
118
+ * sentinel (the cursor lived elsewhere then), and treating it as a cursor would
119
+ * send the tool chasing a build named "active". No derived slug collides with it
120
+ * (they carry a YYYY-MM-DD- prefix).
121
+ */
88
122
  function readCursor(root) {
89
123
  let content;
90
124
  try {
@@ -95,75 +129,113 @@ function readCursor(root) {
95
129
  }
96
130
  return content.length > 0 && content !== 'active' ? content : null;
97
131
  }
98
- // The build a verb should act on, plus its argv with the `--build <slug>` pair
99
- // stripped (D28). Every verb resolves through this: an explicit `--build <slug>`
100
- // wins, else the cursor / sole-build fallback of `activeBuild`. `rest` matters
101
- // because the slug is a bare token — scanning positionals on the raw argv would let
102
- // it masquerade as a step number or a spike slug, so callers scan `rest` instead.
132
+ /**
133
+ * The build a verb should act on, plus its argv with the `--build <slug>` pair
134
+ * stripped.
135
+ *
136
+ * Every verb resolves through this: an explicit `--build <slug>` wins, else the
137
+ * cursor / sole-build fallback of `activeBuild`. `rest` matters because the slug
138
+ * is a bare token — scanning positionals on the raw argv would let it masquerade
139
+ * as a step number or a spike slug, so callers scan `rest` instead.
140
+ */
103
141
  export function resolveBuild(root, args) {
104
142
  const i = args.indexOf('--build');
105
143
  if (i === -1)
106
144
  return { build: activeBuild(root), rest: args };
107
145
  return { build: activeBuild(root, args[i + 1]), rest: [...args.slice(0, i), ...args.slice(i + 2)] };
108
146
  }
147
+ /**
148
+ * The STATE file — session sentinel and active-build cursor in one.
149
+ */
109
150
  function statePath(root) {
110
151
  return join(root, DIRNAME, 'STATE');
111
152
  }
112
- // Where a build's artifacts and in-flight markers live (D26): the `builds/<slug>/`
113
- // folder for the resolved build, else the flat sidecar root. `slug` is the value
114
- // the verb already resolved via `resolveBuild`/`activeBuild`; omit it and the dir
115
- // resolves from the cursor (the default the executor-agnostic path reads lean on).
116
- // Either way a `null` slug falls back to the flat sidecar root, which covers the
117
- // `--local` layout (D26) and any no-cursor/no-build repo, so the path reads stay
118
- // stable even before a tracked build exists or when a "no active session" guard is
119
- // about to fire.
153
+ /**
154
+ * Where a build's artifacts and in-flight markers live: the `builds/<slug>/`
155
+ * folder for the resolved build, else the flat sidecar root.
156
+ *
157
+ * `slug` is the value the verb already resolved via `resolveBuild`/`activeBuild`;
158
+ * omit it and the dir resolves from the cursor (the default the
159
+ * executor-agnostic path reads lean on). Either way a `null` slug falls back to
160
+ * the flat sidecar root, which covers the `--local` layout and any
161
+ * no-cursor/no-build repo — the path reads stay stable even before a tracked
162
+ * build exists or when a "no active session" guard is about to fire.
163
+ */
120
164
  function artifactDir(root, slug) {
121
165
  const resolved = slug === undefined ? activeBuild(root) : slug;
122
166
  return resolved === null ? sidecarDir(root) : buildDir(root, resolved);
123
167
  }
124
- // The resolved build's artifact folder — the `builds/<slug>/` dir for the active
125
- // build (or the flat sidecar root under `--local`/no-cursor). Public so the
126
- // plan-approval commit can stage exactly this build's scaffold and nothing else
127
- // (D36); `slug` follows the same resolution as the path helpers above.
168
+ /**
169
+ * The resolved build's artifact folder — the `builds/<slug>/` dir for the active
170
+ * build (or the flat sidecar root under `--local`/no-cursor).
171
+ *
172
+ * Public so the plan-approval commit can stage exactly this build's scaffold and
173
+ * nothing else; `slug` follows the same resolution as the path helpers above.
174
+ */
128
175
  export function buildFolder(root, slug) {
129
176
  return artifactDir(root, slug);
130
177
  }
131
- // The SPIKE marker (a single-purpose presence flag, like SEAM/STEP): written by
132
- // `spike` on open, removed on `spike done`. Its existence is the one signal that
133
- // the dashboard and the spike gates read to know "a spike is active".
178
+ /**
179
+ * The SPIKE marker — a single-purpose presence flag, like SEAM/STEP.
180
+ *
181
+ * Written by `spike` on open, removed on `spike done`. Its existence is the one
182
+ * signal the dashboard and the spike gates read to know "a spike is active".
183
+ */
134
184
  export function spikePath(root, slug) {
135
185
  return join(artifactDir(root, slug), 'SPIKE');
136
186
  }
137
- // SEAM and STEP carry the in-flight step (D4/D7): a plain path list and a bare
138
- // number, so the hooks read them with a grep and no markdown parsing.
187
+ /**
188
+ * SEAM — the in-flight step's edit grant: a plain list of the paths the step may
189
+ * touch, one per line, so the hooks read it with a grep and no markdown parsing.
190
+ */
139
191
  export function seamPath(root, slug) {
140
192
  return join(artifactDir(root, slug), 'SEAM');
141
193
  }
194
+ /**
195
+ * STEP — the number of the step in flight, a bare number in a flat file.
196
+ */
142
197
  export function stepPath(root, slug) {
143
198
  return join(artifactDir(root, slug), 'STEP');
144
199
  }
200
+ /**
201
+ * The build's `checkpoints` ledger — the recorded SHAs of landed plan and step
202
+ * commits, one line each.
203
+ */
145
204
  export function checkpointsPath(root, slug) {
146
205
  return join(artifactDir(root, slug), 'checkpoints');
147
206
  }
207
+ /**
208
+ * intent.md — the build's plan: steps, seams, decisions, done-when.
209
+ */
148
210
  export function intentPath(root, slug) {
149
211
  return join(artifactDir(root, slug), 'intent.md');
150
212
  }
213
+ /**
214
+ * build-log.md — the human-facing build ledger (the Steps mirror and the Log).
215
+ */
151
216
  export function buildLogPath(root, slug) {
152
217
  return join(artifactDir(root, slug), 'build-log.md');
153
218
  }
154
- // report.md sits beside intent.md / build-log.md inside the build folder. The
155
- // pb-finish skill writes it and `finish` commits it with the folder — the folder
156
- // IS the archive now (D29), so the report rides the branch into the PR instead of
157
- // being copied into a local-only `archive/` (which retired with `archive.ts`).
219
+ /**
220
+ * report.md — the close-out report, beside intent.md / build-log.md.
221
+ *
222
+ * The finish skill writes it and `finish` commits it with the folder; the
223
+ * tracked build folder is the archive, so the report rides the branch into the
224
+ * PR rather than living in some local-only copy.
225
+ */
158
226
  export function reportPath(root, slug) {
159
227
  return join(artifactDir(root, slug), 'report.md');
160
228
  }
161
- // Spike reports (D70): `spike-NN-<slug>.md` in the build folder, beside report.md.
162
- // One per spike — an explicit `/pb-spike` or a planned `spike:` step — so a build with
163
- // several forks tells its decision history through the folder listing. NN is
164
- // CLI-allocated (the human never numbers them), zero-padded for a stable sort.
165
- // The existing spike-report filenames in the build folder, sorted. Empty when the
166
- // folder is absent or holds none.
229
+ // --- Spike reports: `spike-NN-<slug>.md` files in the build folder, beside
230
+ // report.md. One per spike — an explicit `/plumbbob:spike` or a planned `spike:` step —
231
+ // so a build with several forks tells its decision history through the folder
232
+ // listing. NN is CLI-allocated (the human never numbers them), zero-padded for a
233
+ // stable sort. ---
234
+ /**
235
+ * The existing spike-report filenames in the build folder, sorted.
236
+ *
237
+ * Empty when the folder is absent or holds none.
238
+ */
167
239
  export function listSpikeReports(root, slug) {
168
240
  try {
169
241
  return readdirSync(artifactDir(root, slug))
@@ -174,27 +246,39 @@ export function listSpikeReports(root, slug) {
174
246
  return [];
175
247
  }
176
248
  }
177
- // The path for a NEW spike report: the next free zero-padded index (one past the
178
- // highest existing NN, so a gap never reallocates a number) and the sanitized spike
179
- // slug. Pure allocation — the caller writes the stamped template there.
249
+ /**
250
+ * The path for a NEW spike report: the next free zero-padded index and the
251
+ * sanitized spike slug.
252
+ *
253
+ * One past the highest existing NN, so a gap never reallocates a number. Pure
254
+ * allocation — the caller writes the stamped template there.
255
+ */
180
256
  export function nextSpikeReportPath(root, slug, spikeSlug) {
181
257
  const used = listSpikeReports(root, slug).map((name) => Number(name.slice('spike-'.length, 'spike-'.length + 2)));
182
258
  const next = (used.length > 0 ? Math.max(...used) : 0) + 1;
183
259
  const nn = String(next).padStart(2, '0');
184
260
  return join(artifactDir(root, slug), `spike-${nn}-${spikeSlug}.md`);
185
261
  }
186
- // handoff.json is the agent-run ledger (D47): each `agent run` appends its
187
- // validated envelope here so a later invocation can thread earlier results into
188
- // the next call's `context[]` — the CLI itself is memoryless between runs, and
189
- // the file survives context-window compaction where inline stdout does not. It is
190
- // in-flight control state like STEP/SEAM: untracked (excludeControl), scoped to
191
- // the current step, cleared when the step checkpoints (`clearHandoff`).
262
+ /**
263
+ * handoff.json — the agent-run ledger.
264
+ *
265
+ * Each `agent run` appends its validated envelope here so a later invocation can
266
+ * thread earlier results into the next call's `context[]` — the CLI itself is
267
+ * memoryless between runs, and the file survives context-window compaction where
268
+ * inline stdout does not. It is in-flight control state like STEP/SEAM:
269
+ * untracked (excludeControl), scoped to the current step, cleared when the step
270
+ * checkpoints (`clearHandoff`).
271
+ */
192
272
  export function handoffPath(root, slug) {
193
273
  return join(artifactDir(root, slug), 'handoff.json');
194
274
  }
195
- // Append one entry to the build's handoff.json, creating the file (as a JSON
196
- // array) when absent and tolerating a malformed existing file by starting fresh —
197
- // a corrupt ledger must never wedge a run. Pretty-printed so it stays readable.
275
+ /**
276
+ * Append one entry to the build's handoff.json.
277
+ *
278
+ * Creates the file (as a JSON array) when absent and tolerates a malformed
279
+ * existing file by starting fresh — a corrupt ledger must never wedge a run.
280
+ * Pretty-printed so it stays readable.
281
+ */
198
282
  export function appendHandoff(root, slug, entry) {
199
283
  const path = handoffPath(root, slug);
200
284
  let entries = [];
@@ -210,16 +294,25 @@ export function appendHandoff(root, slug, entry) {
210
294
  mkdirSync(dirname(path), { recursive: true });
211
295
  writeFileSync(path, `${JSON.stringify(entries, null, 2)}\n`);
212
296
  }
213
- // Remove the build's handoff.json — the step-scoped ledger is cleared when the
214
- // step checkpoints (D47), the same beat that clears STEP/SEAM. Absent file is a
215
- // no-op (`force`).
297
+ /**
298
+ * Remove the build's handoff.json — the step-scoped ledger clears when the step
299
+ * checkpoints, the same beat that clears STEP/SEAM.
300
+ *
301
+ * An absent file is a no-op (`force`).
302
+ */
216
303
  export function clearHandoff(root, slug) {
217
304
  rmSync(handoffPath(root, slug), { force: true });
218
305
  }
306
+ /**
307
+ * stats.json — the per-build stats receipt, beside checkpoints.
308
+ */
219
309
  export function statsPath(root, slug) {
220
310
  return join(artifactDir(root, slug), 'stats.json');
221
311
  }
222
- // The whole file, or {} when absent or corrupt — malformed contributes nothing.
312
+ /**
313
+ * The whole stats file, or {} when absent or corrupt — malformed contributes
314
+ * nothing.
315
+ */
223
316
  export function readStats(root, slug) {
224
317
  try {
225
318
  const parsed = JSON.parse(readFileSync(statsPath(root, slug), 'utf8'));
@@ -229,16 +322,30 @@ export function readStats(root, slug) {
229
322
  return {};
230
323
  }
231
324
  }
232
- // Increment one counter on one step. Best-effort by contract: a failed write is
233
- // swallowed — the stats are a receipt, never a gate on the verb that accrues them.
325
+ /**
326
+ * Increment one counter on one step.
327
+ *
328
+ * Best-effort by contract: a failed write is swallowed — the stats are a
329
+ * receipt, never a gate on the verb that accrues them.
330
+ */
234
331
  export function bumpStepStat(root, slug, step, key) {
235
332
  patchStepStat(root, slug, step, (current) => ({ [key]: (current[key] ?? 0) + 1 }));
236
333
  }
237
- // Stamp one timestamp on one step (startedAt at `build <n>`, landedAt at
238
- // checkpoint). Same best-effort contract as bumpStepStat.
334
+ /**
335
+ * Stamp one timestamp on one step — startedAt at `build <n>`, landedAt at
336
+ * checkpoint.
337
+ *
338
+ * Same best-effort contract as bumpStepStat.
339
+ */
239
340
  export function stampStepStat(root, slug, step, key, value) {
240
341
  patchStepStat(root, slug, step, () => ({ [key]: value }));
241
342
  }
343
+ /**
344
+ * Read-modify-write one step's stats entry, swallowing any failure.
345
+ *
346
+ * The catch is deliberate: the stats are a receipt, and a failed write must
347
+ * never wedge the build/checkpoint/revert verb that accrued it.
348
+ */
242
349
  function patchStepStat(root, slug, step, patch) {
243
350
  try {
244
351
  const stats = readStats(root, slug);
@@ -249,57 +356,92 @@ function patchStepStat(root, slug, step, patch) {
249
356
  writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
250
357
  }
251
358
  catch {
252
- // Best-effort receipt (D27/D4): never wedge build/checkpoint/revert over it.
359
+ // Best-effort receipt: never wedge build/checkpoint/revert over it.
253
360
  }
254
361
  }
255
- // A session exists iff STATE exists. Deleting STATE (at finish) is what flips the
256
- // repo back to "no session" — so it is the single source of truth for "is there
257
- // a session". `start` calls beginSession; `finish` removes the file. Existence is
258
- // the whole session signal; STATE's content is a separate axis (the cursor).
362
+ /**
363
+ * A session exists iff STATE exists.
364
+ *
365
+ * Deleting STATE (at finish) is what flips the repo back to "no session" — the
366
+ * file is the single source of truth for "is there a session". `start` calls
367
+ * beginSession; `finish` removes the file. Existence is the whole session
368
+ * signal; STATE's content is a separate axis (the cursor).
369
+ */
259
370
  export function hasSession(root) {
260
371
  return existsSync(statePath(root));
261
372
  }
262
- // Open the session and point its cursor at `slug` — null under --local / no build,
263
- // which leaves STATE present but empty so activeBuild falls through to the sole-build
264
- // rule. `finish` removes the file, closing the session and clearing the cursor in
265
- // one delete.
373
+ /**
374
+ * Open the session and point its cursor at `slug`.
375
+ *
376
+ * Null under --local / no build, which leaves STATE present but empty so
377
+ * activeBuild falls through to the sole-build rule. `finish` removes the file,
378
+ * closing the session and clearing the cursor in one delete.
379
+ */
266
380
  export function beginSession(root, slug = null) {
267
381
  writeCursor(root, slug);
268
382
  }
269
- // Re-point the cursor at an existing build (`use`) — a plain content rewrite that
270
- // leaves the session sentinel (STATE's existence) intact. Callers guard hasSession
271
- // first, so this never resurrects a finished session.
383
+ /**
384
+ * Re-point the cursor at an existing build (`use`) — a plain content rewrite
385
+ * that leaves the session sentinel (STATE's existence) intact.
386
+ *
387
+ * Callers guard hasSession first, so this never resurrects a finished session.
388
+ */
272
389
  export function setActiveBuild(root, slug) {
273
390
  writeCursor(root, slug);
274
391
  }
392
+ /**
393
+ * Write STATE's content: empty for no build, else the slug plus newline.
394
+ */
275
395
  function writeCursor(root, slug) {
276
396
  writeFileSync(statePath(root), slug === null ? '' : `${slug}\n`);
277
397
  }
278
- // SPIKE marker helpers — existence is the whole signal (content is irrelevant).
398
+ /**
399
+ * True while a spike is open — the SPIKE marker exists (content is irrelevant).
400
+ */
279
401
  export function inSpike(root, slug) {
280
402
  return existsSync(spikePath(root, slug));
281
403
  }
404
+ /**
405
+ * Drop the SPIKE marker — existence is the whole signal.
406
+ */
282
407
  export function markSpike(root, slug) {
283
408
  writeFileSync(spikePath(root, slug), 'active\n');
284
409
  }
410
+ /**
411
+ * Remove the SPIKE marker (`spike done`); absent is a no-op.
412
+ */
285
413
  export function clearSpike(root, slug) {
286
414
  rmSync(spikePath(root, slug), { force: true });
287
415
  }
288
- // --- The turn ledger (D64/D65): a `.plumbbob/TURN` count the model never writes,
289
- // and a one-turn `.plumbbob/GRANT` beside it — both flat, per-worktree control
290
- // (never per-build, never committed). `plumbbob turn` (the UserPromptSubmit hook)
291
- // ticks TURN once per human prompt and rewrites GRANT from the literal prompt; the
292
- // checkpoint latch reads them. Kept in the sidecar so the lone GRANT `rmSync` stays
293
- // where deletions are allowed (C4/centralize-destructive-fs). ---
416
+ // --- The turn ledger: the checkpoint latch's inputs. `.plumbbob/TURN` is a count
417
+ // of human prompts the model never writes, with a one-turn `.plumbbob/GRANT`
418
+ // beside it — both flat, per-worktree control (never per-build, never committed).
419
+ // `plumbbob turn` (the UserPromptSubmit hook) ticks TURN once per human prompt
420
+ // and rewrites GRANT from the literal prompt; the checkpoint latch reads them to
421
+ // refuse a land until a human turn has intervened since the step was entered.
422
+ // Kept in the sidecar so the lone GRANT `rmSync` stays where deletions of control
423
+ // state are allowed. ---
424
+ /**
425
+ * TURN — the human-turn count the UserPromptSubmit hook maintains.
426
+ */
294
427
  export function turnPath(root) {
295
428
  return join(root, DIRNAME, 'TURN');
296
429
  }
430
+ /**
431
+ * GRANT — the one-turn self-approval minted from the human's literal prompt.
432
+ */
297
433
  export function grantPath(root) {
298
434
  return join(root, DIRNAME, 'GRANT');
299
435
  }
300
- // Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
301
- // (`auto` | `range M`), null clears it. Pairing the write with its delete here means
302
- // a grant can never half-persist, and the delete lives where deletions belong.
436
+ /**
437
+ * Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
438
+ * (`auto` | `range M`), null clears it.
439
+ *
440
+ * The turn hook calls this on every human prompt — minted on a match, cleared
441
+ * otherwise — so a grant lives exactly one turn by construction. Pairing the
442
+ * write with its delete here means a grant can never half-persist, and the
443
+ * delete lives where deletions belong.
444
+ */
303
445
  export function setGrant(root, grant) {
304
446
  if (grant === null) {
305
447
  rmSync(grantPath(root), { force: true });
@@ -308,31 +450,44 @@ export function setGrant(root, grant) {
308
450
  writeFileSync(grantPath(root), `${grant}\n`);
309
451
  }
310
452
  }
311
- // TICK is the per-build entry stamp (D64): the TURN value recorded when work was
312
- // entered (`build <n>` for a step, `start` for the plan), cleared when `checkpoint`
313
- // lands. The latch compares TURN against it to know whether a human turn intervened
314
- // across the entry→checkpoint span.
453
+ /**
454
+ * TICK — the per-build entry stamp: the TURN value recorded when work was
455
+ * entered (`build <n>` for a step, `start` for the plan), cleared when
456
+ * `checkpoint` lands.
457
+ *
458
+ * The checkpoint latch compares TURN against it to know whether a human turn
459
+ * intervened across the entry→checkpoint span.
460
+ */
315
461
  export function tickPath(root, slug) {
316
462
  return join(artifactDir(root, slug), 'TICK');
317
463
  }
318
- // Stamp TICK with the current TURN. Stamped only when TURN holds a readable count:
319
- // a host with no hooks never grows a ledger, so the latch stays dormant there
320
- // instead of wedging (row 2 of the matrix).
464
+ /**
465
+ * Stamp TICK with the current TURN.
466
+ *
467
+ * Stamped only when TURN holds a readable count: a host with no hooks never
468
+ * grows a ledger, so the latch stays dormant there instead of wedging.
469
+ */
321
470
  export function stampTick(root, slug) {
322
471
  const turn = readTurn(root);
323
472
  if (turn === null)
324
473
  return;
325
474
  writeFileSync(tickPath(root, slug), `${turn}\n`);
326
475
  }
327
- // Consume the entry stamp — `checkpoint` clears it when a step (or the plan) lands,
328
- // the same beat that clears STEP/SEAM/handoff. Absent is a no-op; the rmSync lives
329
- // here with the sidecar's other deletions.
476
+ /**
477
+ * Consume the entry stamp — `checkpoint` clears it when a step (or the plan)
478
+ * lands, the same beat that clears STEP/SEAM/handoff.
479
+ *
480
+ * Absent is a no-op; the rmSync lives here with the sidecar's other deletions.
481
+ */
330
482
  export function clearTick(root, slug) {
331
483
  rmSync(tickPath(root, slug), { force: true });
332
484
  }
333
- // The TURN count, or null when the ledger is absent or unreadable — absence is the
334
- // "dormant" signal stampTick keys off and the doctor latch probe reports (D64),
335
- // never an error.
485
+ /**
486
+ * The TURN count, or null when the ledger is absent or unreadable.
487
+ *
488
+ * Absence is the "dormant" signal stampTick keys off and the doctor latch probe
489
+ * reports — never an error.
490
+ */
336
491
  export function readTurn(root) {
337
492
  try {
338
493
  const n = Number.parseInt(readFileSync(turnPath(root), 'utf8').trim(), 10);
@@ -342,11 +497,14 @@ export function readTurn(root) {
342
497
  return null;
343
498
  }
344
499
  }
345
- // Walk up from `cwd` to the nearest ancestor with an active session (a
346
- // `.plumbbob/STATE` file), or null when there is none. The turn hook runs on every
347
- // human prompt in every repo; this is the cheap, git-free probe that keeps it a
348
- // silent no-op outside a live plumbbob session — filesystem only, never a host
349
- // sniff (C5/D13).
500
+ /**
501
+ * Walk up from `cwd` to the nearest ancestor with an active session (a
502
+ * `.plumbbob/STATE` file), or null when there is none.
503
+ *
504
+ * The turn hook runs on every human prompt in every repo; this is the cheap,
505
+ * git-free probe that keeps it a silent no-op outside a live plumbbob session —
506
+ * filesystem only, never a host sniff.
507
+ */
350
508
  export function findSessionRoot(cwd) {
351
509
  let dir = cwd;
352
510
  let parent = dirname(dir);
@@ -358,10 +516,15 @@ export function findSessionRoot(cwd) {
358
516
  }
359
517
  return hasSession(dir) ? dir : null;
360
518
  }
361
- // Append `patterns` to the repo's info/exclude, each at most once (idempotent —
362
- // a re-`start` after finish must not double-add). `gitPath` resolves to the
363
- // *common* gitdir's exclude — the only one git reads — so this works from a
364
- // linked worktree, whose per-worktree gitdir has no `info/` (D33).
519
+ /**
520
+ * Append `patterns` to the repo's info/exclude, each at most once.
521
+ *
522
+ * Idempotent — a re-`start` after finish must not double-add. `gitPath` resolves
523
+ * to the *common* gitdir's exclude — the only one git reads — so this works from
524
+ * a linked worktree, whose per-worktree gitdir has no `info/`. info/exclude
525
+ * keeps the exclusion personal machinery, never something imposed on the repo's
526
+ * own tracked ignore file.
527
+ */
365
528
  function addExcludes(root, patterns) {
366
529
  const exclude = gitPath(root, 'info/exclude');
367
530
  mkdirSync(dirname(exclude), { recursive: true });
@@ -379,37 +542,80 @@ function addExcludes(root, patterns) {
379
542
  const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
380
543
  appendFileSync(exclude, `${prefix}${missing.join('\n')}\n`);
381
544
  }
382
- // The narrowed control plane (D17): with `builds/<slug>/` now tracked, only the
383
- // per-worktree control files stay git-excluded — the session sentinel STATE (whose
384
- // content is the active-build cursor, D28), the personal settings overlay, and the
385
- // in-flight step markers inside every build. Everything else under `.plumbbob/`
386
- // (settings.json, and each build's intent/build-log/checkpoints/report) rides the
387
- // branch into the PR.
545
+ /**
546
+ * Git-exclude the control plane while the artifact plane stays tracked.
547
+ *
548
+ * With `builds/<slug>/` tracked, only the per-worktree control files are
549
+ * excluded — the session sentinel STATE (whose content is the active-build
550
+ * cursor), the personal settings overlay, the turn ledger, and the in-flight
551
+ * markers inside every build. Everything else under `.plumbbob/` (settings.json,
552
+ * and each build's intent/build-log/checkpoints/report) rides the branch into
553
+ * the PR.
554
+ */
388
555
  export function excludeControl(root) {
389
556
  addExcludes(root, [
390
557
  `${DIRNAME}/STATE`,
391
558
  `${DIRNAME}/settings.local.json`,
392
- // The turn ledger and its one-turn grant (D64/D65): per-worktree control the
393
- // model never writes, and never commits.
559
+ // The turn ledger and its one-turn grant: per-worktree control the model
560
+ // never writes, and never commits.
394
561
  `${DIRNAME}/TURN`,
395
562
  `${DIRNAME}/GRANT`,
396
563
  `${DIRNAME}/builds/*/STEP`,
397
564
  `${DIRNAME}/builds/*/SEAM`,
398
565
  `${DIRNAME}/builds/*/SPIKE`,
399
- // The entry stamp (D64) is in-flight control like STEP/SEAM — checkpoint's
566
+ // The entry stamp is in-flight control like STEP/SEAM — checkpoint's
400
567
  // stageAll must never sweep it into a step commit.
401
568
  `${DIRNAME}/builds/*/TICK`,
402
- // The agent-run handoff ledger (D47) is step-scoped in-flight state, not a
569
+ // The agent-run handoff ledger is step-scoped in-flight state, not a
403
570
  // tracked artifact — it must never ride a step commit into the PR.
404
571
  `${DIRNAME}/builds/*/handoff.json`,
405
- // The checkride gate (D32) writes raw tool output to `.check/`; checkpoint's
572
+ // The checkride gate writes raw tool output to `.check/`; checkpoint's
406
573
  // stageAll must never sweep it into a step commit.
407
574
  '.check/',
408
575
  ]);
409
576
  }
410
- // D26: `start --local` opts out of the tracked layout into a fully-untracked
411
- // sidecar (today's behavior) — some team repos won't accept tool folders in-tree.
412
- // Excludes the whole `.plumbbob/` directory.
577
+ /**
578
+ * `start --local`: opt out of the tracked layout into a fully-untracked sidecar —
579
+ * some team repos won't accept tool folders in-tree.
580
+ *
581
+ * Excludes the whole `.plumbbob/` directory.
582
+ */
413
583
  export function excludeSidecar(root) {
414
584
  addExcludes(root, [`${DIRNAME}/`, '.check/']);
415
585
  }
586
+ /**
587
+ * Whether info/exclude already carries `pattern` verbatim.
588
+ *
589
+ * Absent or unreadable reads as "no" — the caller's job is to write it, not to
590
+ * fail on a repo that has never been excluded.
591
+ */
592
+ function hasExclude(root, pattern) {
593
+ try {
594
+ return readFileSync(gitPath(root, 'info/exclude'), 'utf8')
595
+ .split('\n')
596
+ .some((line) => line.trim() === pattern);
597
+ }
598
+ catch {
599
+ return false;
600
+ }
601
+ }
602
+ /**
603
+ * Re-apply the control-plane excludes on the way into a `git add -A`.
604
+ *
605
+ * `start` writes them once, but a plumbbob upgraded mid-build can add a control
606
+ * file the running session's exclude never learned — TICK, GRANT and
607
+ * handoff.json all arrived that way — and checkpoint/finish stage with `-A`,
608
+ * which would sweep the unexcluded file into the commit and ride it into the PR.
609
+ * Idempotent, so the common path is one read and no write.
610
+ *
611
+ * A fully-untracked sidecar is already covered by its blanket `.plumbbob/` line;
612
+ * layering the narrow patterns underneath would only add noise, so skip it. That
613
+ * check reads info/exclude rather than the cursor because a null cursor means
614
+ * "`--local` OR no build named" — and calling excludeSidecar on the tracked
615
+ * layout would untrack the whole artifact plane.
616
+ */
617
+ export function refreshExcludes(root) {
618
+ if (hasExclude(root, `${DIRNAME}/`))
619
+ return;
620
+ excludeControl(root);
621
+ }