plumbbob 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -10
  3. package/dist/cli-core.js +25 -3
  4. package/dist/lib/git.js +20 -8
  5. package/dist/lib/notice.js +15 -1
  6. package/dist/lib/orient.js +266 -19
  7. package/dist/lib/sidecar.js +149 -3
  8. package/dist/verbs/abandon.js +19 -14
  9. package/dist/verbs/build.js +45 -22
  10. package/dist/verbs/checkpoint.js +34 -25
  11. package/dist/verbs/doctor.js +23 -4
  12. package/dist/verbs/finish.js +21 -17
  13. package/dist/verbs/handoff.js +235 -65
  14. package/dist/verbs/order.js +110 -0
  15. package/dist/verbs/park.js +6 -2
  16. package/dist/verbs/recover.js +31 -1
  17. package/dist/verbs/relay.js +306 -0
  18. package/dist/verbs/revert.js +17 -11
  19. package/dist/verbs/spike.js +50 -38
  20. package/dist/verbs/start.js +13 -9
  21. package/dist/verbs/status.js +2 -2
  22. package/dist/verbs/turn.js +14 -7
  23. package/dist/verbs/use.js +21 -17
  24. package/hooks/hooks.json +7 -0
  25. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  26. package/node_modules/checkride/CHANGELOG.md +117 -0
  27. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  28. package/node_modules/checkride/dist/adapters.js +14 -10
  29. package/node_modules/checkride/dist/adapters.js.map +1 -1
  30. package/node_modules/checkride/dist/baseline/fallow.d.ts +4 -3
  31. package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -1
  32. package/node_modules/checkride/dist/baseline/fallow.js +15 -8
  33. package/node_modules/checkride/dist/baseline/fallow.js.map +1 -1
  34. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  35. package/node_modules/checkride/dist/gate.js +62 -44
  36. package/node_modules/checkride/dist/gate.js.map +1 -1
  37. package/node_modules/checkride/dist/init.js +3 -3
  38. package/node_modules/checkride/dist/init.js.map +1 -1
  39. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  40. package/node_modules/checkride/dist/orchestrator.js +53 -8
  41. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  42. package/node_modules/checkride/dist/pack.js +3 -3
  43. package/node_modules/checkride/dist/security.d.ts +4 -2
  44. package/node_modules/checkride/dist/security.d.ts.map +1 -1
  45. package/node_modules/checkride/dist/security.js +42 -8
  46. package/node_modules/checkride/dist/security.js.map +1 -1
  47. package/node_modules/checkride/dist/triage/render.js +1 -1
  48. package/node_modules/checkride/dist/triage/render.js.map +1 -1
  49. package/node_modules/checkride/package.json +10 -10
  50. package/node_modules/checkride/skills/check/SKILL.md +4 -3
  51. package/node_modules/checkride/templates/shared/cspell.json +3 -24
  52. package/package.json +3 -3
  53. package/skills/build/SKILL.md +47 -20
  54. package/skills/harvest/SKILL.md +4 -1
  55. package/skills/order/SKILL.md +50 -0
  56. package/skills/park/SKILL.md +7 -0
  57. package/skills/plan/SKILL.md +4 -1
  58. package/skills/refine/SKILL.md +6 -2
  59. package/skills/step/SKILL.md +8 -3
  60. package/skills/verify/SKILL.md +42 -22
  61. 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.13.0",
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
@@ -140,12 +140,14 @@ In Claude Code, inside any git repo with a clean tree:
140
140
  nothing is written around it.
141
141
 
142
142
  ````markdown
143
+ **Now**: Step 1 of 3 - feat(limiter): add a token-bucket limiter
144
+
143
145
  **Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute. (details: `.plumbbob/detail.md`)
144
146
 
145
147
  1. The bucket lives in memory, keyed by IP, refilling five tokens a minute.
146
148
  2. The clock is injected, so the test advances time instead of sleeping on it.
147
149
 
148
- **Readout**: Step 1 - feat(limiter): add a token-bucket limiter
150
+ **Readout**:
149
151
 
150
152
  ```text
151
153
  check green: 7 of 7 checks
@@ -164,7 +166,7 @@ In Claude Code, inside any git repo with a clean tree:
164
166
  **Your Call**:
165
167
 
166
168
  - `looks good` → I checkpoint Step 1; back to the boundary
167
- - `expand`, or any question → I show more of what is there; nothing changes
169
+ - `expand`, or any question → I show more of what is there; nothing changes, and `show the pause` brings all of this back
168
170
  - anything that reads as direction → I take it as what to change; nothing lands until you approve
169
171
  - `revert` → I wind the work back to the last checkpoint
170
172
 
@@ -192,8 +194,10 @@ In Claude Code, inside any git repo with a clean tree:
192
194
  ![The boundary after looks good](docs/media/checkpoint-boundary.svg)
193
195
 
194
196
  Ask instead (`expand 2`, or any question) and it shows more of what is there and
195
- changes nothing; direct it and it takes that as the fix, and nothing lands until you
196
- say `looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
197
+ changes nothing. The answer closes on one line pointing back at the step, not on the
198
+ whole block again, since you have already read it; say `show the pause` to see the
199
+ block again. Direct it and it takes that as the fix, and nothing lands until you say
200
+ `looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
197
201
  *is* the clock tick. Whenever you lose the thread, `/plumbbob:status` shows where you
198
202
  are and names the next move.
199
203
  4. **Finish.** When the last step is done, `/plumbbob:finish` writes a report of what shipped
@@ -254,9 +258,10 @@ All fourteen, with inputs and effects, are in
254
258
  [`docs/skills-reference.md`](docs/skills-reference.md).
255
259
 
256
260
  Under the skills ships a lean `plumbbob` CLI (the mechanical verbs the
257
- skills shell out to), three hooks (a non-blocking post-edit lint, a `git commit`
258
- ask-hook while a step is in flight, and the per-prompt turn ledger the approval latch
259
- reads), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
261
+ skills shell out to), four hooks (a non-blocking post-edit lint, a `git commit`
262
+ ask-hook while a step is in flight, the per-prompt turn ledger the approval latch
263
+ reads, and a check at each turn's end that the turn relayed the ending plumbbob
264
+ printed), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
260
265
  `builds/<slug>/` folder per build (`intent.md`, `build-log.md`, `checkpoints`, `report.md`)
261
266
  that rides the branch into the PR, plus an untracked control plane (`settings.local.json`,
262
267
  the session sentinel, the in-flight markers).
@@ -334,7 +339,7 @@ that matters.
334
339
  > under [`docs/evals/`](docs/evals/): the route the latch owns (*no checkpoint over a red
335
340
  > check under pressure*) went from **2/5 prose-only to 5/5 latched** on the first sweep
336
341
  > ([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))
342
+ > two gaps that one surfaced, and the latest ([2026-09-17](docs/evals/2026-09-17.md))
338
343
  > scores the shape of the turn itself.
339
344
 
340
345
  ## Documentation
@@ -346,7 +351,7 @@ Each doc answers one question (in rough reading order for a new user):
346
351
  - *Show me the artifacts it leaves behind.* → [`examples/`](examples/): that same session's finished build folder, file by file.
347
352
  - *Should I / can I / what about…?* → [`docs/faq.md`](docs/faq.md): the adoption questions, answered straight.
348
353
  - *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.
354
+ - *What does each skill do?* → [`docs/skills-reference.md`](docs/skills-reference.md): all fifteen skills: inputs, effects, when to reach for each.
350
355
  - *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
356
  - *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
357
  - *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";
@@ -27,6 +28,7 @@ import { init } from "./verbs/init.js";
27
28
  import { doctor } from "./verbs/doctor.js";
28
29
  import { recover } from "./verbs/recover.js";
29
30
  import { agent } from "./verbs/agent.js";
31
+ import { relay } from "./verbs/relay.js";
30
32
  import { turn } from "./verbs/turn.js";
31
33
  /** Shared across every verb that resolves through the active-build cursor. */
32
34
  const BUILD_FLAG = {
@@ -79,15 +81,16 @@ const VERBS = [
79
81
  },
80
82
  {
81
83
  name: 'handoff',
82
- description: 'print the footer card that ends a turn (pause, boundary, plan, or driver); read-only',
83
- synopsis: ['handoff [<n>] [--plan] [--driver] [--build <slug>]'],
84
+ description: 'print the footer card that ends a turn (pause, boundary, plan, or driver), or a reminder for a pause already shown',
85
+ synopsis: ['handoff [<n>] [--full] [--plan] [--driver] [--build <slug>]'],
84
86
  args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
85
87
  flags: [
88
+ { name: '--full', gloss: 'print the whole pause card even when that pause was already shown' },
86
89
  { name: '--plan', gloss: 'render the plan-pause card instead of a step tier' },
87
90
  { name: '--driver', gloss: "render a driver turn's pointer back at the open step" },
88
91
  BUILD_FLAG,
89
92
  ],
90
- notes: 'Read-only; refuses (exit 1) with no session, or with --plan and --driver together.',
93
+ notes: 'Writes only the detail file (a blank line around each heading) and the PAUSE stamp; refuses (exit 1) with no session, or with --plan and --driver together.',
91
94
  },
92
95
  {
93
96
  name: 'check',
@@ -140,6 +143,14 @@ const VERBS = [
140
143
  tolerateUnknownFlags: true,
141
144
  notes: 'Refuses (exit 1) with no session, empty text, or no `## Park list` section.',
142
145
  },
146
+ {
147
+ name: 'order',
148
+ description: 'set the build order: the undone steps in the sequence to build them',
149
+ synopsis: ['order <n> [<n>…]', 'order --reset'],
150
+ args: [{ name: '<n>…', gloss: 'undone step numbers in the sequence to build them; the rest follow in document order' }],
151
+ flags: [{ name: '--reset', gloss: 'drop the line, so the plan reads in document order again' }],
152
+ 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.',
153
+ },
143
154
  {
144
155
  name: 'spike',
145
156
  description: 'worktree experiment + report for a genuine fork',
@@ -216,6 +227,13 @@ const VERBS = [
216
227
  tolerateUnknownFlags: true,
217
228
  notes: 'Reads the hook payload from stdin and always exits 0 — a broken hook must never wedge a prompt.',
218
229
  },
230
+ {
231
+ name: 'relay',
232
+ description: 'Stop hook machinery: check that the turn carried the ending it printed (not a user verb)',
233
+ synopsis: ['relay'],
234
+ tolerateUnknownFlags: true,
235
+ notes: "Reads the hook payload from stdin and always exits 0: a broken hook must never wedge a turn's end.",
236
+ },
219
237
  ];
220
238
  /**
221
239
  * Render the verb table for `plumbbob help`.
@@ -340,6 +358,8 @@ async function dispatch(verb, cwd, rest) {
340
358
  return status(cwd, rest);
341
359
  case 'park':
342
360
  return park(cwd, rest);
361
+ case 'order':
362
+ return order(cwd, rest);
343
363
  case 'build':
344
364
  return build(cwd, rest);
345
365
  case 'handoff':
@@ -368,6 +388,8 @@ async function dispatch(verb, cwd, rest) {
368
388
  return agent(cwd, rest);
369
389
  case 'turn':
370
390
  return turn(cwd, rest);
391
+ case 'relay':
392
+ return relay(cwd, rest);
371
393
  default:
372
394
  process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
373
395
  return 1;
package/dist/lib/git.js CHANGED
@@ -68,18 +68,30 @@ export function hasCommit(root) {
68
68
  export function isDirty(root) {
69
69
  return runGit(root, ['status', '--porcelain']).length > 0;
70
70
  }
71
+ /**
72
+ * The body line that marks a plan commit made by hand (a `chore(plan)`
73
+ * harvest). A harvest lands between nearly every step, so counting it as
74
+ * out-of-band would trip the advisory on routine housekeeping and stop meaning
75
+ * anything within three steps. `status` and `handoff` both pass it to
76
+ * `commitsSince`, which is what keeps the two counts one quantity.
77
+ */
78
+ export const PLAN_COMMIT_MARKER = '^plumbbob plan$';
71
79
  /**
72
80
  * The number of commits on HEAD not reachable from `sha`: `git rev-list
73
81
  * --count --first-parent <sha>..HEAD`.
74
82
  *
75
- * `status` reads this for its receipts line: one neutral note when commits
76
- * landed since the last checkpoint outside plumbbob's ledger (out-of-band
77
- * commits are surfaced, never blocked). `--first-parent` keeps the count on
78
- * the branch's own line: merging upstream reads as the one merge commit, not
79
- * the dozens it carried; those didn't land "outside the ledger" in any sense
80
- * the receipt should nag about. `excluding` is a commit-message pattern whose
81
- * matches are left out of the count, which is how a caller drops plumbbob's own
82
- * commits from a tally of what landed around them.
83
+ * `status` and `handoff` both read this for their out-of-band count: one
84
+ * neutral note when commits landed since the last checkpoint outside
85
+ * plumbbob's ledger (out-of-band commits are surfaced, never blocked). Two
86
+ * mechanisms keep plumbbob's own commits out of that count. A commit any
87
+ * plumbbob verb makes is recorded in the ledger and becomes the anchor, so the
88
+ * range starts after it. A plan commit made by hand carries
89
+ * `PLAN_COMMIT_MARKER` on a line of its body, and both callers pass it as
90
+ * `excluding` so the range drops it. `--first-parent` keeps the count on the
91
+ * branch's own line: merging upstream reads as the one merge commit, not the
92
+ * dozens it carried; those didn't land "outside the ledger" in any sense the
93
+ * receipt should nag about. `excluding` is a commit-message pattern whose
94
+ * matches are left out of the count.
83
95
  *
84
96
  * Best-effort and never throws: 0 when the range is empty, `sha` is unknown to
85
97
  * the repo, or HEAD is unborn: the count is informational (the human commits
@@ -11,7 +11,10 @@
11
11
  // the edge, and an optional indented arrow line carrying what to do next. One
12
12
  // shape, so moving it is one edit rather than a sweep. The pointer at the next
13
13
  // step is not a line's job: `handoff` renders one, and the verb prints it as
14
- // the ending's last part. Pure string assembly, no fs, no gate.
14
+ // the ending's last part. The formatters are pure string assembly, no fs, no
15
+ // gate; `printEnding` is the one function here that writes, and every ending
16
+ // a verb prints goes through it so the relay check knows what was said.
17
+ import { writeRelay } from "./sidecar.js";
15
18
  /** The column budget a notice line aims to fit, matching the readout fence. */
16
19
  const WIDTH = 80;
17
20
  /** The fewest detail items a degraded parenthetical still names, so the count has a universe to size. */
@@ -83,6 +86,17 @@ export function blocks(parts) {
83
86
  const kept = parts.filter((p) => p !== null && p.trim().length > 0);
84
87
  return `${kept.map((p) => p.replace(/\n+$/, '')).join('\n\n')}\n\n`;
85
88
  }
89
+ /**
90
+ * Print an ending to stdout and record it in RELAY, for the Stop check that
91
+ * the turn closed on it.
92
+ *
93
+ * Stdout first, and the record never throws: a failed write leaves the
94
+ * printed ending and the verb's exit code exactly as they would have been.
95
+ */
96
+ export function printEnding(root, relay) {
97
+ process.stdout.write(relay.text);
98
+ writeRelay(root, relay);
99
+ }
86
100
  /**
87
101
  * The head, its trailing parenthetical, and the remedy line beneath, assembled.
88
102
  *
@@ -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 : [],
@@ -702,6 +903,45 @@ export function parseSummary(detail) {
702
903
  }
703
904
  return { lead, highlights };
704
905
  }
906
+ /**
907
+ * `.plumbbob/detail.md` with a blank line around every heading, and everything
908
+ * else as written.
909
+ *
910
+ * A model copying the detail template can run a heading straight into its body
911
+ * or its predecessor, and markdownlint's MD022 fails that file wherever
912
+ * `.plumbbob/` is linted, which turns a downstream checkpoint red. Heading lines
913
+ * inside a fenced block are content, not headings, so a fence is left whole.
914
+ * Pure and idempotent: text that already has its blank lines comes back byte for
915
+ * byte, so a caller can compare the result to the input to learn whether
916
+ * anything needed fixing.
917
+ */
918
+ export function normalizeDetailHeadings(detail) {
919
+ const out = [];
920
+ let fence = null;
921
+ let afterHeading = false;
922
+ for (const line of detail.split('\n')) {
923
+ if (fence !== null) {
924
+ const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
925
+ if (close !== undefined && close.startsWith(fence.char) && close.length >= fence.length) {
926
+ fence = null;
927
+ }
928
+ out.push(line);
929
+ continue;
930
+ }
931
+ const heading = /^ {0,3}#{1,6}(\s|$)/.test(line);
932
+ const prior = out[out.length - 1];
933
+ if ((heading || afterHeading) && line.trim() !== '' && prior !== undefined && prior.trim() !== '') {
934
+ out.push('');
935
+ }
936
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
937
+ if (open !== undefined) {
938
+ fence = { char: open.charAt(0), length: open.length };
939
+ }
940
+ out.push(line);
941
+ afterHeading = heading;
942
+ }
943
+ return out.join('\n');
944
+ }
705
945
  /**
706
946
  * The `## <n>` sections of `.plumbbob/detail.md` with their bodies: the full
707
947
  * story behind each highlight, in the order the model wrote them. The body is
@@ -756,10 +996,10 @@ export function parseDetailStep(detail) {
756
996
  */
757
997
  export function formatOrientation(o) {
758
998
  const doneCount = o.steps.filter((s) => s.done).length;
759
- const nextUndone = o.steps.find((s) => !s.done);
999
+ const nextUndone = o.nextUndone === null ? undefined : o.steps.find((s) => s.n === o.nextUndone);
760
1000
  // One arrow, always: an explicitly requested step takes the marker and the
761
1001
  // detail rows, so the injected state never argues with the invocation. With
762
- // no (valid) request the next undone step keeps them, as ever.
1002
+ // no (valid) request the next undone step in build order keeps them, as ever.
763
1003
  const requestedStep = o.requested === null ? undefined : o.steps.find((s) => s.n === o.requested);
764
1004
  const marked = requestedStep ?? nextUndone;
765
1005
  const stepLines = o.steps.map((s) => {
@@ -788,10 +1028,16 @@ export function formatOrientation(o) {
788
1028
  const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
789
1029
  // A neutral reconciliation note, only when there is something to reconcile:
790
1030
  // commits landed since the last checkpoint that plumbbob's ledger didn't
791
- // record. Informational: the human commits freely, so this never gates.
1031
+ // record. A commit a plumbbob verb makes starts the range, and a hand-made
1032
+ // plan commit drops out on its `plumbbob plan` body marker, so the count is
1033
+ // the same one `handoff` folds into its Verdict. Informational: the human
1034
+ // commits freely, so this never gates.
792
1035
  const receipts = o.outOfBand > 0
793
1036
  ? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
794
1037
  : [];
1038
+ // The build order, whole, only when it departs from the numbering: the
1039
+ // dashboard is the reference surface, so nothing is folded to a count here.
1040
+ const orderRow = o.buildOrder.length > 0 ? [`build order ${o.buildOrder.join(', ')}`] : [];
795
1041
  return [
796
1042
  `PlumbBob — ${o.title ?? '(untitled)'} [${o.phase}]`,
797
1043
  '',
@@ -799,7 +1045,8 @@ export function formatOrientation(o) {
799
1045
  '',
800
1046
  cpLine,
801
1047
  ...receipts,
802
- `parked ${o.parked} · open questions ${o.openQuestions}`,
1048
+ ...orderRow,
1049
+ `${parkedPhrase({ open: o.parked, total: o.parkedTotal })} · open questions ${o.openQuestions}`,
803
1050
  '',
804
1051
  `next → ${o.next}`,
805
1052
  ].join('\n');