plumbbob 0.12.0 → 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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/dist/cli-core.js +11 -0
  4. package/dist/lib/orient.js +223 -18
  5. package/dist/verbs/build.js +45 -22
  6. package/dist/verbs/checkpoint.js +4 -4
  7. package/dist/verbs/handoff.js +64 -13
  8. package/dist/verbs/order.js +102 -0
  9. package/dist/verbs/recover.js +31 -1
  10. package/dist/verbs/turn.js +7 -6
  11. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  12. package/node_modules/checkride/CHANGELOG.md +117 -0
  13. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  14. package/node_modules/checkride/dist/adapters.js +14 -10
  15. package/node_modules/checkride/dist/adapters.js.map +1 -1
  16. package/node_modules/checkride/dist/baseline/fallow.d.ts +4 -3
  17. package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -1
  18. package/node_modules/checkride/dist/baseline/fallow.js +15 -8
  19. package/node_modules/checkride/dist/baseline/fallow.js.map +1 -1
  20. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  21. package/node_modules/checkride/dist/gate.js +62 -44
  22. package/node_modules/checkride/dist/gate.js.map +1 -1
  23. package/node_modules/checkride/dist/init.js +3 -3
  24. package/node_modules/checkride/dist/init.js.map +1 -1
  25. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  26. package/node_modules/checkride/dist/orchestrator.js +53 -8
  27. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  28. package/node_modules/checkride/dist/pack.js +3 -3
  29. package/node_modules/checkride/dist/security.d.ts +4 -2
  30. package/node_modules/checkride/dist/security.d.ts.map +1 -1
  31. package/node_modules/checkride/dist/security.js +42 -8
  32. package/node_modules/checkride/dist/security.js.map +1 -1
  33. package/node_modules/checkride/dist/triage/render.js +1 -1
  34. package/node_modules/checkride/dist/triage/render.js.map +1 -1
  35. package/node_modules/checkride/package.json +10 -10
  36. package/node_modules/checkride/skills/check/SKILL.md +4 -3
  37. package/node_modules/checkride/templates/shared/cspell.json +3 -24
  38. package/package.json +3 -3
  39. package/skills/build/SKILL.md +12 -4
  40. package/skills/harvest/SKILL.md +4 -1
  41. package/skills/order/SKILL.md +50 -0
  42. package/skills/plan/SKILL.md +3 -1
  43. package/skills/refine/SKILL.md +6 -2
  44. package/skills/step/SKILL.md +8 -3
  45. package/skills/verify/SKILL.md +10 -7
  46. package/templates/intent.md +9 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.12.0",
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
@@ -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':
@@ -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 : [],
@@ -756,10 +957,10 @@ export function parseDetailStep(detail) {
756
957
  */
757
958
  export function formatOrientation(o) {
758
959
  const doneCount = o.steps.filter((s) => s.done).length;
759
- const nextUndone = o.steps.find((s) => !s.done);
960
+ const nextUndone = o.nextUndone === null ? undefined : o.steps.find((s) => s.n === o.nextUndone);
760
961
  // One arrow, always: an explicitly requested step takes the marker and the
761
962
  // detail rows, so the injected state never argues with the invocation. With
762
- // 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.
763
964
  const requestedStep = o.requested === null ? undefined : o.steps.find((s) => s.n === o.requested);
764
965
  const marked = requestedStep ?? nextUndone;
765
966
  const stepLines = o.steps.map((s) => {
@@ -792,6 +993,9 @@ export function formatOrientation(o) {
792
993
  const receipts = o.outOfBand > 0
793
994
  ? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
794
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(', ')}`] : [];
795
999
  return [
796
1000
  `PlumbBob — ${o.title ?? '(untitled)'} [${o.phase}]`,
797
1001
  '',
@@ -799,7 +1003,8 @@ export function formatOrientation(o) {
799
1003
  '',
800
1004
  cpLine,
801
1005
  ...receipts,
802
- `parked ${o.parked} · open questions ${o.openQuestions}`,
1006
+ ...orderRow,
1007
+ `${parkedPhrase({ open: o.parked, total: o.parkedTotal })} · open questions ${o.openQuestions}`,
803
1008
  '',
804
1009
  `next → ${o.next}`,
805
1010
  ].join('\n');
@@ -8,7 +8,7 @@ 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
13
  import { readGrant } from "../lib/latch.js";
14
14
  import { notice } from "../lib/notice.js";
@@ -18,8 +18,8 @@ import { notice } from "../lib/notice.js";
18
18
  * Refuses a missing session, a malformed step argument, an `N-M` range (a
19
19
  * `/plumbbob:build` skill feature, not a CLI one), and a seam that fails to parse.
20
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 says so when the step is the top of a
22
- * range the human typed.
21
+ * build-log's Current step line, and names the step when it is the last one a
22
+ * self-approval grant reaches.
23
23
  */
24
24
  export function build(cwd, args) {
25
25
  const root = findRepoRoot(cwd);
@@ -48,7 +48,9 @@ export function build(cwd, args) {
48
48
  return 1;
49
49
  }
50
50
  const intent = readFileSync(intentPath(root, slug), 'utf8');
51
- 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);
52
54
  // No argument ⇒ enter the next undone step in intent.md (the same idiom
53
55
  // `checkpoint` uses), so a bare `plumbbob build` advances the loop without the
54
56
  // skill re-deriving the step in prose. Every step checkpointed ⇒ a `/plumbbob:step`
@@ -89,30 +91,20 @@ export function build(cwd, args) {
89
91
  const title = steps.find((s) => s.n === step)?.title ?? null;
90
92
  syncBuildLogState(root, slug, stepLabel(step, title));
91
93
  // An explicit jump past undone work says so out loud: the entered step and
92
- // the count it skips read back to the human (and to the transcript), so a
93
- // deliberate `build 22` and a confused one look different on the page.
94
- 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;
95
98
  const picked = raw === undefined
96
99
  ? ['next undone']
97
100
  : skipped > 0
98
101
  ? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
99
102
  : [];
100
- // The top step of a typed range lands like every step under it, and the turn
101
- // ends on its checkpoint. Saying so as the step is entered puts that fact in
102
- // front of the model before it chooses between landing the step and holding
103
- // it at a pause, which is the choice a range kept getting wrong.
104
- const grant = readGrant(root);
105
- const top = grant?.kind === 'range' && grant.ceiling === step
106
- ? notice({
107
- fact: `step ${step} is the top of the range you granted`,
108
- remedy: 'land it on green, then stop at the boundary',
109
- })
110
- : '';
111
- // Two lines (three at a range's top), one colon each, then the seam as a
112
- // plain readout beneath the notice that frames it: the paths are a list, and
113
- // 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.
114
106
  process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
115
- top +
107
+ lastGrantedNotice(readGrant(root), steps, step) +
116
108
  notice({
117
109
  fact: 'the seam is orientation, not a lock',
118
110
  detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
@@ -120,3 +112,34 @@ export function build(cwd, args) {
120
112
  `${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
121
113
  return 0;
122
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, parseDetailStep, 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";
@@ -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;