plumbbob 0.11.3 → 0.12.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -4
  3. package/dist/cli-core.js +11 -0
  4. package/dist/lib/latch.js +1 -1
  5. package/dist/lib/orient.js +233 -18
  6. package/dist/verbs/build.js +51 -12
  7. package/dist/verbs/checkpoint.js +17 -10
  8. package/dist/verbs/handoff.js +64 -13
  9. package/dist/verbs/order.js +102 -0
  10. package/dist/verbs/recover.js +31 -1
  11. package/dist/verbs/turn.js +7 -6
  12. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  13. package/node_modules/checkride/CHANGELOG.md +117 -0
  14. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  15. package/node_modules/checkride/dist/adapters.js +14 -10
  16. package/node_modules/checkride/dist/adapters.js.map +1 -1
  17. package/node_modules/checkride/dist/baseline/fallow.d.ts +4 -3
  18. package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -1
  19. package/node_modules/checkride/dist/baseline/fallow.js +15 -8
  20. package/node_modules/checkride/dist/baseline/fallow.js.map +1 -1
  21. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  22. package/node_modules/checkride/dist/gate.js +62 -44
  23. package/node_modules/checkride/dist/gate.js.map +1 -1
  24. package/node_modules/checkride/dist/init.js +3 -3
  25. package/node_modules/checkride/dist/init.js.map +1 -1
  26. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  27. package/node_modules/checkride/dist/orchestrator.js +53 -8
  28. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  29. package/node_modules/checkride/dist/pack.js +3 -3
  30. package/node_modules/checkride/dist/security.d.ts +4 -2
  31. package/node_modules/checkride/dist/security.d.ts.map +1 -1
  32. package/node_modules/checkride/dist/security.js +42 -8
  33. package/node_modules/checkride/dist/security.js.map +1 -1
  34. package/node_modules/checkride/dist/triage/render.js +1 -1
  35. package/node_modules/checkride/dist/triage/render.js.map +1 -1
  36. package/node_modules/checkride/package.json +10 -10
  37. package/node_modules/checkride/skills/check/SKILL.md +4 -3
  38. package/node_modules/checkride/templates/shared/cspell.json +3 -24
  39. package/package.json +3 -3
  40. package/skills/build/SKILL.md +48 -26
  41. package/skills/finish/SKILL.md +5 -2
  42. package/skills/harvest/SKILL.md +4 -1
  43. package/skills/order/SKILL.md +50 -0
  44. package/skills/plan/SKILL.md +5 -2
  45. package/skills/refine/SKILL.md +6 -2
  46. package/skills/step/SKILL.md +8 -3
  47. package/skills/verify/SKILL.md +14 -8
  48. package/templates/intent.md +9 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.11.3",
4
+ "version": "0.12.1",
5
5
  "description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  <a href="https://robmclarty.github.io/plumbbob/api.html">API reference</a>
11
11
  </p>
12
12
 
13
- PlumbBob is a Claude Code plugin (fourteen skills and a small CLI) that runs
13
+ PlumbBob is a Claude Code plugin (fifteen skills and a small CLI) that runs
14
14
  LLM-assisted coding as a loop you control. You and the model settle the plan in a file
15
15
  *before* any code; then the model builds one small step at a time and **stops after
16
16
  each one**, waiting for you to read the diff and approve it before anything is
@@ -221,7 +221,7 @@ nothing else:
221
221
  | Skill <img alt="" width="110" height="1"> | Does |
222
222
  | ------------------------------------ | ------ |
223
223
  | `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps** |
224
- | `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3` self-approves through step 3, then pauses |
224
+ | `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3` self-approves through step 3, then stops |
225
225
  | `/plumbbob:finish` | finish up: write the report, make the final commit, clear for a fresh goal |
226
226
 
227
227
  **Plan-shaping moves**: optional, for when the plan needs work mid-flight:
@@ -334,7 +334,7 @@ that matters.
334
334
  > under [`docs/evals/`](docs/evals/): the route the latch owns (*no checkpoint over a red
335
335
  > check under pressure*) went from **2/5 prose-only to 5/5 latched** on the first sweep
336
336
  > ([2026-07-11](docs/evals/2026-07-11.md)), every sweep since has held it and closed the
337
- > two gaps that one surfaced, and the latest ([2026-09-03](docs/evals/2026-09-03.md))
337
+ > two gaps that one surfaced, and the latest ([2026-09-17](docs/evals/2026-09-17.md))
338
338
  > scores the shape of the turn itself.
339
339
 
340
340
  ## Documentation
@@ -346,7 +346,7 @@ Each doc answers one question (in rough reading order for a new user):
346
346
  - *Show me the artifacts it leaves behind.* → [`examples/`](examples/): that same session's finished build folder, file by file.
347
347
  - *Should I / can I / what about…?* → [`docs/faq.md`](docs/faq.md): the adoption questions, answered straight.
348
348
  - *What is each method for?* → [`docs/techniques.md`](docs/techniques.md): steps, seams, the pause, park/harvest, spikes.
349
- - *What does each skill do?* → [`docs/skills-reference.md`](docs/skills-reference.md): all fourteen skills: inputs, effects, when to reach for each.
349
+ - *What does each skill do?* → [`docs/skills-reference.md`](docs/skills-reference.md): all fifteen skills: inputs, effects, when to reach for each.
350
350
  - *What does a turn look like, and who renders which part?* → [`docs/presentation.md`](docs/presentation.md): the anatomy of the pause, the boundary, and the driver turns.
351
351
  - *How do I plug in my own agent?* → [`docs/agents.md`](docs/agents.md): the subprocess envelope, the manifest, `harness.json`, and working examples (including a local-model reviewer via Ollama).
352
352
  - *How do I get a local model reviewing my steps?* → [`docs/local-model-review.md`](docs/local-model-review.md): the ollama-reviewer example walked end to end, install to every-pause review.
package/dist/cli-core.js CHANGED
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url';
14
14
  import { start } from "./verbs/start.js";
15
15
  import { status } from "./verbs/status.js";
16
16
  import { park } from "./verbs/park.js";
17
+ import { order } from "./verbs/order.js";
17
18
  import { build } from "./verbs/build.js";
18
19
  import { handoff } from "./verbs/handoff.js";
19
20
  import { check } from "./verbs/check.js";
@@ -140,6 +141,14 @@ const VERBS = [
140
141
  tolerateUnknownFlags: true,
141
142
  notes: 'Refuses (exit 1) with no session, empty text, or no `## Park list` section.',
142
143
  },
144
+ {
145
+ name: 'order',
146
+ description: 'set the build order: the undone steps in the sequence to build them',
147
+ synopsis: ['order <n> [<n>…]', 'order --reset'],
148
+ args: [{ name: '<n>…', gloss: 'undone step numbers in the sequence to build them; the rest follow in document order' }],
149
+ flags: [{ name: '--reset', gloss: 'drop the line, so the plan reads in document order again' }],
150
+ notes: 'Writes the one numeric line under `## Build order` in intent.md, which status, build, checkpoint, and handoff all read. Refuses (exit 1) with no session, no numbers, a number the plan lacks, a repeat, or a step already checkpointed.',
151
+ },
143
152
  {
144
153
  name: 'spike',
145
154
  description: 'worktree experiment + report for a genuine fork',
@@ -340,6 +349,8 @@ async function dispatch(verb, cwd, rest) {
340
349
  return status(cwd, rest);
341
350
  case 'park':
342
351
  return park(cwd, rest);
352
+ case 'order':
353
+ return order(cwd, rest);
343
354
  case 'build':
344
355
  return build(cwd, rest);
345
356
  case 'handoff':
package/dist/lib/latch.js CHANGED
@@ -112,7 +112,7 @@ export function checkLatch(root, step) {
112
112
  /**
113
113
  * The current GRANT, or null when the file is absent or unparseable.
114
114
  */
115
- function readGrant(root) {
115
+ export function readGrant(root) {
116
116
  try {
117
117
  return parseGrant(readFileSync(grantPath(root), 'utf8'));
118
118
  }
@@ -15,11 +15,15 @@ function sectionLines(content, heading) {
15
15
  if (start === -1) {
16
16
  return { offset: 0, lines: [] };
17
17
  }
18
- let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
19
- if (end === -1) {
20
- end = lines.length;
21
- }
22
- return { offset: start + 1, lines: lines.slice(start + 1, end) };
18
+ return { offset: start + 1, lines: lines.slice(start + 1, sectionEnd(lines, start)) };
19
+ }
20
+ /**
21
+ * The index of the line that ends the section opened at `heading`: the next
22
+ * `## ` heading, or one past the last line when none follows.
23
+ */
24
+ function sectionEnd(lines, heading) {
25
+ const end = lines.findIndex((l, i) => i > heading && l.startsWith('## '));
26
+ return end === -1 ? lines.length : end;
23
27
  }
24
28
  /**
25
29
  * The build title: the first `# ` heading in intent.md, or null when absent.
@@ -70,6 +74,166 @@ export function parseSteps(intent) {
70
74
  return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model, line: offset + s.idx + 1 };
71
75
  });
72
76
  }
77
+ // A build-order line: step numbers and commas, nothing else. A trailing comma
78
+ // is tolerated, since a hand edit leaves one behind.
79
+ const BUILD_ORDER_LINE = /^\d+(?:\s*,\s*\d+)*\s*,?$/;
80
+ /**
81
+ * The build order declared under `## Build order`: the step numbers on the
82
+ * first line that is nothing but numbers and commas, in the sequence written.
83
+ *
84
+ * The line is the plan's own statement of what to build next once the
85
+ * numbering stops carrying it (a refined plan appends a step 7 that has to
86
+ * land before step 5), and `plumbbob order` is the verb that writes it. The
87
+ * template's guidance, a comment, and an absent section all read as `[]`,
88
+ * which every picker takes to mean document order. Numbers come back
89
+ * verbatim, repeats and unknowns included: `orderSteps` does the cleaning, and
90
+ * `recover` wants the raw read to say which number the plan lacks.
91
+ */
92
+ export function parseBuildOrder(intent) {
93
+ const line = sectionLines(intent, '## Build order')
94
+ .lines.map((l) => l.trim())
95
+ .find((l) => BUILD_ORDER_LINE.test(l));
96
+ if (line === undefined) {
97
+ return [];
98
+ }
99
+ return line
100
+ .split(',')
101
+ .map((n) => n.trim())
102
+ .filter((n) => n.length > 0)
103
+ .map(Number);
104
+ }
105
+ /**
106
+ * The steps in build order: the numbers `order` lists first, in that sequence,
107
+ * then every step it leaves out, in document order.
108
+ *
109
+ * A listed number takes the first step in document order that carries it, and
110
+ * a repeat of the number is ignored, so a doubled entry cannot double a step; a
111
+ * number no step carries is dropped. An empty order is the identity, which is
112
+ * what a plan with no `## Build order` section reads as. The steps themselves
113
+ * never change, only the sequence, so `n`, `line`, and `done` stay what
114
+ * `parseSteps` read.
115
+ */
116
+ export function orderSteps(steps, order) {
117
+ const taken = new Set();
118
+ const ordered = [];
119
+ for (const n of order) {
120
+ const step = steps.find((s) => s.n === n);
121
+ if (step !== undefined && !taken.has(step)) {
122
+ taken.add(step);
123
+ ordered.push(step);
124
+ }
125
+ }
126
+ return [...ordered, ...steps.filter((s) => !taken.has(s))];
127
+ }
128
+ /**
129
+ * The plan's steps in build order: `parseSteps` merged with `parseBuildOrder`.
130
+ *
131
+ * The one call every next-step picker makes, so the dashboard's `← next`, a
132
+ * bare `plumbbob build`, `checkpoint`'s fallback, and the card's Next Up
133
+ * cannot disagree about which step comes next.
134
+ */
135
+ export function parseOrderedSteps(intent) {
136
+ return orderSteps(parseSteps(intent), parseBuildOrder(intent));
137
+ }
138
+ /**
139
+ * The undone steps that come before step `n` in `ordered`: what an explicit
140
+ * `build n` skips. Empty when `n` is not in the list.
141
+ */
142
+ export function skippedBefore(ordered, n) {
143
+ const at = ordered.findIndex((s) => s.n === n);
144
+ return at === -1 ? [] : ordered.slice(0, at).filter((s) => !s.done);
145
+ }
146
+ /**
147
+ * The reminder the card and the dashboard render: the undone step numbers
148
+ * (minus `exclude`, the step a pause is about) in build order, when that
149
+ * sequence departs from document order; `[]` when the numbering already
150
+ * carries it, so a plan nobody re-sequenced renders nothing new.
151
+ */
152
+ export function remainingOrder(steps, order, exclude) {
153
+ const left = (list) => list.filter((s) => !s.done && s.n !== exclude).map((s) => s.n);
154
+ const byOrder = left(orderSteps(steps, order));
155
+ const byDocument = left(steps);
156
+ return byOrder.some((n, i) => n !== byDocument[i]) ? byOrder : [];
157
+ }
158
+ /**
159
+ * The build order as a phrase: the next `cap` step numbers in sequence, the
160
+ * rest folded to a count (`and 2 more`), the way a long detail degrades on
161
+ * every one-liner; '' for an empty order. With no cap, the whole sequence,
162
+ * which is the dashboard's row.
163
+ */
164
+ export function buildOrderPhrase(order, cap) {
165
+ if (order.length === 0) {
166
+ return '';
167
+ }
168
+ const shown = cap === undefined ? order : order.slice(0, cap);
169
+ const rest = order.length - shown.length;
170
+ return `build order ${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
171
+ }
172
+ /**
173
+ * Write `order` as the one numeric line under `## Build order`: in place of
174
+ * the line already there, else last in the section, else in a new section
175
+ * opened right after `## Steps`, so the sequence sits beside the steps it
176
+ * sequences. Mechanical bookkeeping for `plumbbob order`, the way
177
+ * `markStepDone` is for `checkpoint`.
178
+ */
179
+ export function setBuildOrder(intent, order) {
180
+ const lines = intent.split('\n');
181
+ const text = order.join(', ');
182
+ const heading = lines.findIndex((l) => l.trim() === '## Build order');
183
+ if (heading !== -1) {
184
+ const end = sectionEnd(lines, heading);
185
+ const at = lines.findIndex((l, i) => i > heading && i < end && BUILD_ORDER_LINE.test(l.trim()));
186
+ if (at !== -1) {
187
+ lines[at] = text;
188
+ return lines.join('\n');
189
+ }
190
+ insertAfterContent(lines, heading, end, [text]);
191
+ return lines.join('\n');
192
+ }
193
+ const steps = lines.findIndex((l) => l.trim() === '## Steps');
194
+ const end = steps === -1 ? lines.length : sectionEnd(lines, steps);
195
+ insertAfterContent(lines, steps, end, ['## Build order', '', text]);
196
+ return lines.join('\n');
197
+ }
198
+ /**
199
+ * Splice `block` in after the last non-blank line before `end` (and after
200
+ * `from`), one blank line on each side, without doubling a blank that is
201
+ * already there.
202
+ */
203
+ function insertAfterContent(lines, from, end, block) {
204
+ let last = end - 1;
205
+ while (last > from && (lines[last] ?? '').trim() === '') {
206
+ last -= 1;
207
+ }
208
+ const at = last + 1;
209
+ const followed = at < lines.length && (lines[at] ?? '').trim() === '';
210
+ lines.splice(at, 0, '', ...block, ...(followed ? [] : ['']));
211
+ }
212
+ /**
213
+ * Drop the numeric line under `## Build order`, and the section itself when
214
+ * nothing but blank lines is left in it, so the plan reads in document order
215
+ * again. A plan with no line is returned unchanged.
216
+ */
217
+ export function clearBuildOrder(intent) {
218
+ const lines = intent.split('\n');
219
+ const heading = lines.findIndex((l) => l.trim() === '## Build order');
220
+ if (heading === -1) {
221
+ return intent;
222
+ }
223
+ const at = lines.findIndex((l, i) => i > heading && i < sectionEnd(lines, heading) && BUILD_ORDER_LINE.test(l.trim()));
224
+ if (at === -1) {
225
+ return intent;
226
+ }
227
+ lines.splice(at, 1);
228
+ if ((lines[at - 1] ?? '').trim() === '' && (at >= lines.length || (lines[at] ?? '').trim() === '')) {
229
+ lines.splice(at - 1, 1);
230
+ }
231
+ const body = lines.slice(heading + 1, sectionEnd(lines, heading));
232
+ if (body.every((l) => l.trim() === '')) {
233
+ lines.splice(heading, body.length + 1);
234
+ }
235
+ return lines.join('\n');
236
+ }
73
237
  /**
74
238
  * Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section.
75
239
  *
@@ -118,15 +282,43 @@ export function parseOpenQuestions(intent) {
118
282
  }).length;
119
283
  }
120
284
  /**
121
- * Count the open parked items: `- [ ]` lines under `## Park list`.
285
+ * Count the park list: the `- [ ]` lines still open, and every checklist line
286
+ * under `## Park list`, harvested `- [x]` ones included.
122
287
  *
123
288
  * A parked item is a mid-build idea the `park` verb appends as a flat checklist
124
- * line for later triage; `/plumbbob:harvest` flips a triaged one to `- [x]` and it
125
- * stops counting. The `(none yet)` placeholder and the blockquote instructions
126
- * never match.
289
+ * line for later triage; `/plumbbob:harvest` flips a triaged one to `- [x]`, so
290
+ * the pair reads `parked 2 of 7 open` and a harvest shows as the gap between
291
+ * the two. The `(none yet)` placeholder and the blockquote instructions never
292
+ * match.
293
+ */
294
+ export function parseParkTotals(buildLog) {
295
+ let open = 0;
296
+ let total = 0;
297
+ for (const line of sectionLines(buildLog, '## Park list').lines) {
298
+ const m = /^-\s+\[([ xX])\]\s+\S/.exec(line.trim());
299
+ if (m === null) {
300
+ continue;
301
+ }
302
+ total += 1;
303
+ if (m[1] === ' ') {
304
+ open += 1;
305
+ }
306
+ }
307
+ return { open, total };
308
+ }
309
+ /**
310
+ * Count the open parked items alone: `- [ ]` lines under `## Park list`.
127
311
  */
128
312
  export function parseParked(buildLog) {
129
- return sectionLines(buildLog, '## Park list').lines.filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
313
+ return parseParkTotals(buildLog).open;
314
+ }
315
+ /**
316
+ * The park count as both surfaces say it: `parked 2 of 7 open` once anything
317
+ * has been parked, the open count against everything ever parked; `parked 0`
318
+ * with nothing on the list yet.
319
+ */
320
+ export function parkedPhrase(totals) {
321
+ return totals.total === 0 ? 'parked 0' : `parked ${totals.open} of ${totals.total} open`;
130
322
  }
131
323
  /**
132
324
  * Count the constraints declared under `## Constraints`: the section's
@@ -225,7 +417,7 @@ function nextMove(spiking, steps, inFlight, parked, requested) {
225
417
  if (target.done) {
226
418
  return `build step ${target.n} — explicitly requested (already checkpointed)`;
227
419
  }
228
- const skipped = steps.filter((s) => !s.done && s.n < target.n).length;
420
+ const skipped = skippedBefore(steps, target.n).length;
229
421
  const notes = [];
230
422
  if (skipped > 0) {
231
423
  notes.push(`skips ${skipped} undone step${skipped === 1 ? '' : 's'}`);
@@ -256,13 +448,19 @@ function nextMove(spiking, steps, inFlight, parked, requested) {
256
448
  */
257
449
  export function orient(input) {
258
450
  const steps = parseSteps(input.intent);
259
- const parked = parseParked(input.buildLog);
451
+ // The build order re-sequences the picks and nothing else: the step block
452
+ // still renders in document order, with the marker showing where the
453
+ // sequence actually goes.
454
+ const order = parseBuildOrder(input.intent);
455
+ const ordered = orderSteps(steps, order);
456
+ const parks = parseParkTotals(input.buildLog);
260
457
  // The target the dashboard details: the explicitly requested step when it
261
458
  // exists in the plan, else the next undone. A requested number that names no
262
459
  // planned step carries nothing here; the next-move line reports the mismatch
263
460
  // while the rest of the dashboard renders as usual.
264
461
  const requestedStep = input.requested === null ? undefined : steps.find((s) => s.n === input.requested);
265
- const target = requestedStep ?? steps.find((s) => !s.done);
462
+ const nextUndone = ordered.find((s) => !s.done);
463
+ const target = requestedStep ?? nextUndone;
266
464
  const seamParse = target === undefined ? null : parseStepSeam(input.intent, target.n);
267
465
  const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
268
466
  return {
@@ -270,9 +468,12 @@ export function orient(input) {
270
468
  phase,
271
469
  steps,
272
470
  lastCheckpoint: parseLastCheckpoint(input.checkpoints),
273
- parked,
471
+ parked: parks.open,
472
+ parkedTotal: parks.total,
274
473
  openQuestions: parseOpenQuestions(input.intent),
275
- next: nextMove(input.spiking, steps, input.inFlight, parked, input.requested),
474
+ next: nextMove(input.spiking, ordered, input.inFlight, parks.open, input.requested),
475
+ nextUndone: nextUndone?.n ?? null,
476
+ buildOrder: remainingOrder(steps, order, null),
276
477
  requested: requestedStep?.n ?? null,
277
478
  nextDoneWhen: target?.doneWhen ?? null,
278
479
  nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
@@ -741,15 +942,25 @@ export function parseSections(detail) {
741
942
  export function parseRecommendation(detail) {
742
943
  return sectionProse(detail, /^##\s+recommendation\s*$/i);
743
944
  }
945
+ /**
946
+ * The step `.plumbbob/detail.md` was written for, read off its
947
+ * `# Detail · Step <n> · <title>` header, or null when the first line is not a
948
+ * step header (a plan pause's detail, or a file the model wrote without one).
949
+ */
950
+ export function parseDetailStep(detail) {
951
+ const first = detail.trimStart().split('\n', 1)[0] ?? '';
952
+ const m = /^#\s+Detail\b.*?\bStep\s+(\d+)\b/.exec(first);
953
+ return m === null ? null : Number(m[1]);
954
+ }
744
955
  /**
745
956
  * Render an Orientation as the plain-text dashboard `status` prints.
746
957
  */
747
958
  export function formatOrientation(o) {
748
959
  const doneCount = o.steps.filter((s) => s.done).length;
749
- const nextUndone = o.steps.find((s) => !s.done);
960
+ const nextUndone = o.nextUndone === null ? undefined : o.steps.find((s) => s.n === o.nextUndone);
750
961
  // One arrow, always: an explicitly requested step takes the marker and the
751
962
  // detail rows, so the injected state never argues with the invocation. With
752
- // no (valid) request the next undone step keeps them, as ever.
963
+ // no (valid) request the next undone step in build order keeps them, as ever.
753
964
  const requestedStep = o.requested === null ? undefined : o.steps.find((s) => s.n === o.requested);
754
965
  const marked = requestedStep ?? nextUndone;
755
966
  const stepLines = o.steps.map((s) => {
@@ -782,6 +993,9 @@ export function formatOrientation(o) {
782
993
  const receipts = o.outOfBand > 0
783
994
  ? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
784
995
  : [];
996
+ // The build order, whole, only when it departs from the numbering: the
997
+ // dashboard is the reference surface, so nothing is folded to a count here.
998
+ const orderRow = o.buildOrder.length > 0 ? [`build order ${o.buildOrder.join(', ')}`] : [];
785
999
  return [
786
1000
  `PlumbBob — ${o.title ?? '(untitled)'} [${o.phase}]`,
787
1001
  '',
@@ -789,7 +1003,8 @@ export function formatOrientation(o) {
789
1003
  '',
790
1004
  cpLine,
791
1005
  ...receipts,
792
- `parked ${o.parked} · open questions ${o.openQuestions}`,
1006
+ ...orderRow,
1007
+ `${parkedPhrase({ open: o.parked, total: o.parkedTotal })} · open questions ${o.openQuestions}`,
793
1008
  '',
794
1009
  `next → ${o.next}`,
795
1010
  ].join('\n');
@@ -8,16 +8,18 @@ import { readFileSync, writeFileSync } from 'node:fs';
8
8
  import { findRepoRoot } from "../lib/git.js";
9
9
  import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTick, stepPath } from "../lib/sidecar.js";
10
10
  import { parseStepSeam } from "../lib/intent.js";
11
- import { parseSteps } from "../lib/orient.js";
11
+ import { parseOrderedSteps, skippedBefore } from "../lib/orient.js";
12
12
  import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
13
+ import { readGrant } from "../lib/latch.js";
13
14
  import { notice } from "../lib/notice.js";
14
15
  /**
15
16
  * Enter step n (or the next undone step) and write its SEAM/STEP markers.
16
17
  *
17
18
  * Refuses a missing session, a malformed step argument, an `N-M` range (a
18
19
  * `/plumbbob:build` skill feature, not a CLI one), and a seam that fails to parse.
19
- * On entry it stamps the turn ledger and the step's start time, and re-renders
20
- * the build-log's Current step line.
20
+ * On entry it stamps the turn ledger and the step's start time, re-renders the
21
+ * build-log's Current step line, and names the step when it is the last one a
22
+ * self-approval grant reaches.
21
23
  */
22
24
  export function build(cwd, args) {
23
25
  const root = findRepoRoot(cwd);
@@ -28,12 +30,13 @@ export function build(cwd, args) {
28
30
  const { build: slug, rest } = resolveBuild(root, args);
29
31
  const raw = rest.find((a) => !a.startsWith('--'));
30
32
  // A step range like `1-3` is a `/plumbbob:build` skill affordance (auto-approve
31
- // through the range, then pause), not a CLI capability: the CLI records one
32
- // in-flight step at a time. Name it rather than bounce off the generic usage.
33
+ // every step through the range's top, then stop), not a CLI capability: the CLI
34
+ // records one in-flight step at a time. Name it rather than bounce off the
35
+ // generic usage.
33
36
  if (raw !== undefined && /^\d+-\d*$/.test(raw)) {
34
37
  process.stderr.write(notice({
35
38
  fact: 'build takes one step number',
36
- detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through it and then pauses`],
39
+ detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through its top and then stops`],
37
40
  remedy: `plumbbob build ${raw.split('-')[0]}`,
38
41
  }));
39
42
  return 1;
@@ -45,7 +48,9 @@ export function build(cwd, args) {
45
48
  return 1;
46
49
  }
47
50
  const intent = readFileSync(intentPath(root, slug), 'utf8');
48
- const steps = parseSteps(intent);
51
+ // In build order: the sequence `## Build order` declares when a refined plan
52
+ // has one, else the numbering, so this pick and the dashboard's agree.
53
+ const steps = parseOrderedSteps(intent);
49
54
  // No argument ⇒ enter the next undone step in intent.md (the same idiom
50
55
  // `checkpoint` uses), so a bare `plumbbob build` advances the loop without the
51
56
  // skill re-deriving the step in prose. Every step checkpointed ⇒ a `/plumbbob:step`
@@ -86,17 +91,20 @@ export function build(cwd, args) {
86
91
  const title = steps.find((s) => s.n === step)?.title ?? null;
87
92
  syncBuildLogState(root, slug, stepLabel(step, title));
88
93
  // An explicit jump past undone work says so out loud: the entered step and
89
- // the count it skips read back to the human (and to the transcript), so a
90
- // deliberate `build 22` and a confused one look different on the page.
91
- const skipped = raw === undefined ? 0 : steps.filter((s) => !s.done && s.n < step).length;
94
+ // the count it skips (the undone steps ahead of it in build order) read back
95
+ // to the human (and to the transcript), so a deliberate `build 22` and a
96
+ // confused one look different on the page.
97
+ const skipped = raw === undefined ? 0 : skippedBefore(steps, step).length;
92
98
  const picked = raw === undefined
93
99
  ? ['next undone']
94
100
  : skipped > 0
95
101
  ? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
96
102
  : [];
97
- // Two lines, one colon each, then the seam as a plain readout beneath the
98
- // notice that frames it: the paths are a list, and a list is not a one-liner.
103
+ // Two lines (three on the last step a grant reaches), one colon each, then
104
+ // the seam as a plain readout beneath the notice that frames it: the paths
105
+ // are a list, and a list is not a one-liner.
99
106
  process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
107
+ lastGrantedNotice(readGrant(root), steps, step) +
100
108
  notice({
101
109
  fact: 'the seam is orientation, not a lock',
102
110
  detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
@@ -104,3 +112,34 @@ export function build(cwd, args) {
104
112
  `${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
105
113
  return 0;
106
114
  }
115
+ /**
116
+ * The line naming the last step a self-approval grant reaches this turn, or ''
117
+ * for any other step and when no grant is live.
118
+ *
119
+ * That step lands like every step before it, and then the turn ends on its
120
+ * checkpoint: a clean halt. Saying so as the step is entered puts the fact in
121
+ * front of the model before it chooses between landing the step and holding it
122
+ * at a pause, which is the choice a clean halt kept getting wrong. A range
123
+ * reaches its top, or the last undone step under its top when the plan ends
124
+ * first, or when the build order puts a number past the top ahead of the rest
125
+ * (a range names plan numbers, and the skill halts before one past its top);
126
+ * `--auto` reaches the plan's last undone step.
127
+ */
128
+ function lastGrantedNotice(grant, steps, step) {
129
+ if (grant === null) {
130
+ return '';
131
+ }
132
+ const remedy = 'land it on green, then stop at the boundary';
133
+ if (grant.kind === 'range') {
134
+ if (step === grant.ceiling) {
135
+ return notice({ fact: `step ${step} is the top of the range you granted`, remedy });
136
+ }
137
+ const after = steps.slice(steps.findIndex((s) => s.n === step) + 1).find((s) => !s.done);
138
+ const more = after !== undefined && after.n <= grant.ceiling;
139
+ return step < grant.ceiling && !more
140
+ ? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
141
+ : '';
142
+ }
143
+ const more = steps.some((s) => !s.done && s.n !== step);
144
+ return more ? '' : notice({ fact: `step ${step} is the last undone step in the plan`, remedy });
145
+ }
@@ -3,7 +3,7 @@
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
- // in-flight STEP, else the next undone step in intent.md. It evaluates the
6
+ // in-flight STEP, else the next undone step in intent.md's build order. It evaluates the
7
7
  // approval latch, gates on a green check, commits any pending work (or records
8
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
@@ -16,7 +16,7 @@ import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, check
16
16
  import { runCheck } from "../lib/check.js";
17
17
  import { readCommitBody } from "../lib/commitbody.js";
18
18
  import { checkLatch } from "../lib/latch.js";
19
- import { markStepDone, parseSteps } from "../lib/orient.js";
19
+ import { markStepDone, parseDetailStep, parseOrderedSteps, parseSteps } from "../lib/orient.js";
20
20
  import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
21
21
  import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
22
22
  import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
@@ -87,7 +87,7 @@ export async function checkpoint(cwd, args) {
87
87
  // tracked ledger is the archive, and it rides the branch where a commit body
88
88
  // would not survive the squash); the commit body keeps its marker and the
89
89
  // lead prose only.
90
- const record = stepRecord(root, activeBuild(root), step, readDetail(root) ?? '');
90
+ const record = stepRecord(root, activeBuild(root), step, readDetail(root, step) ?? '');
91
91
  let sha;
92
92
  // The drift advisory is read off the index (staged, before the commit clears
93
93
  // it) but printed inside the ending below, where the fixed order puts it: the
@@ -165,7 +165,7 @@ function checkpointPlan(root, args) {
165
165
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
166
166
  // The cold read that approved the plan is the Log's first entry, beneath a
167
167
  // dated plan line; the detail file is then spent, the same as at a step.
168
- const pointer = logPlan(root, sha, planRecord(readDetail(root) ?? ''));
168
+ const pointer = logPlan(root, sha, planRecord(readDetail(root, null) ?? ''));
169
169
  clearDetail(root);
170
170
  // Landing the plan consumes `start`'s entry stamp: a later hand-built diff
171
171
  // (no `build <n>`) must find no stale TICK and stay guidance-governed.
@@ -200,7 +200,7 @@ function planSubject(root) {
200
200
  /**
201
201
  * Resolve the step being checkpointed, or null when none can be determined.
202
202
  *
203
- * Explicit arg > in-flight STEP file > first undone step in intent.md. A `-m`
203
+ * Explicit arg > in-flight STEP file > first undone step in build order. A `-m`
204
204
  * value is a message, never a step: `checkpoint -m "2"` must not read as
205
205
  * step 2.
206
206
  */
@@ -214,7 +214,7 @@ function resolveStep(root, args) {
214
214
  return inFlight;
215
215
  }
216
216
  try {
217
- return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => !s.done)?.n ?? null;
217
+ return parseOrderedSteps(readFileSync(intentPath(root), 'utf8')).find((s) => !s.done)?.n ?? null;
218
218
  }
219
219
  catch {
220
220
  return null;
@@ -444,16 +444,23 @@ function fallbackBody(root, step) {
444
444
  }
445
445
  /**
446
446
  * The in-flight step's detail from `.plumbbob/detail.md`, or null when the file
447
- * is absent or empty.
447
+ * is absent, empty, or written for another step.
448
448
  *
449
449
  * The model overwrites it before every pause; checkpoint reads it once here for
450
450
  * the Log's record, and the caller clears it afterward. A missing or blank file
451
- * records nothing.
451
+ * records nothing, and so does one whose header names a different step: that
452
+ * file is a leftover, written after its own step had already landed, and
453
+ * recording it would file one step's story under another. The plan commit
454
+ * passes no step and takes the file as it is.
452
455
  */
453
- function readDetail(root) {
456
+ function readDetail(root, step) {
454
457
  try {
455
458
  const raw = readFileSync(detailPath(root), 'utf8').trim();
456
- return raw.length > 0 ? raw : null;
459
+ if (raw.length === 0) {
460
+ return null;
461
+ }
462
+ const written = step === null ? null : parseDetailStep(raw);
463
+ return written === null || written === step ? raw : null;
457
464
  }
458
465
  catch {
459
466
  return null;