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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -10
- package/dist/cli-core.js +25 -3
- package/dist/lib/git.js +20 -8
- package/dist/lib/notice.js +15 -1
- package/dist/lib/orient.js +266 -19
- package/dist/lib/sidecar.js +149 -3
- package/dist/verbs/abandon.js +19 -14
- package/dist/verbs/build.js +45 -22
- package/dist/verbs/checkpoint.js +34 -25
- package/dist/verbs/doctor.js +23 -4
- package/dist/verbs/finish.js +21 -17
- package/dist/verbs/handoff.js +235 -65
- package/dist/verbs/order.js +110 -0
- package/dist/verbs/park.js +6 -2
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/relay.js +306 -0
- package/dist/verbs/revert.js +17 -11
- package/dist/verbs/spike.js +50 -38
- package/dist/verbs/start.js +13 -9
- package/dist/verbs/status.js +2 -2
- package/dist/verbs/turn.js +14 -7
- package/dist/verbs/use.js +21 -17
- package/hooks/hooks.json +7 -0
- package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
- package/node_modules/checkride/CHANGELOG.md +117 -0
- package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
- package/node_modules/checkride/dist/adapters.js +14 -10
- package/node_modules/checkride/dist/adapters.js.map +1 -1
- package/node_modules/checkride/dist/baseline/fallow.d.ts +4 -3
- package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -1
- package/node_modules/checkride/dist/baseline/fallow.js +15 -8
- package/node_modules/checkride/dist/baseline/fallow.js.map +1 -1
- package/node_modules/checkride/dist/gate.d.ts.map +1 -1
- package/node_modules/checkride/dist/gate.js +62 -44
- package/node_modules/checkride/dist/gate.js.map +1 -1
- package/node_modules/checkride/dist/init.js +3 -3
- package/node_modules/checkride/dist/init.js.map +1 -1
- package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
- package/node_modules/checkride/dist/orchestrator.js +53 -8
- package/node_modules/checkride/dist/orchestrator.js.map +1 -1
- package/node_modules/checkride/dist/pack.js +3 -3
- package/node_modules/checkride/dist/security.d.ts +4 -2
- package/node_modules/checkride/dist/security.d.ts.map +1 -1
- package/node_modules/checkride/dist/security.js +42 -8
- package/node_modules/checkride/dist/security.js.map +1 -1
- package/node_modules/checkride/dist/triage/render.js +1 -1
- package/node_modules/checkride/dist/triage/render.js.map +1 -1
- package/node_modules/checkride/package.json +10 -10
- package/node_modules/checkride/skills/check/SKILL.md +4 -3
- package/node_modules/checkride/templates/shared/cspell.json +3 -24
- package/package.json +3 -3
- package/skills/build/SKILL.md +47 -20
- package/skills/harvest/SKILL.md +4 -1
- package/skills/order/SKILL.md +50 -0
- package/skills/park/SKILL.md +7 -0
- package/skills/plan/SKILL.md +4 -1
- package/skills/refine/SKILL.md +6 -2
- package/skills/step/SKILL.md +8 -3
- package/skills/verify/SKILL.md +42 -22
- package/templates/intent.md +9 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "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 (
|
|
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**:
|
|
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
|

|
|
193
195
|
|
|
194
196
|
Ask instead (`expand 2`, or any question) and it shows more of what is there and
|
|
195
|
-
changes nothing
|
|
196
|
-
|
|
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),
|
|
258
|
-
ask-hook while a step is in flight,
|
|
259
|
-
reads
|
|
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-
|
|
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
|
|
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)
|
|
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: '
|
|
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`
|
|
76
|
-
* landed since the last checkpoint outside
|
|
77
|
-
* commits are surfaced, never blocked).
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
package/dist/lib/notice.js
CHANGED
|
@@ -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.
|
|
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
|
*
|
package/dist/lib/orient.js
CHANGED
|
@@ -15,11 +15,15 @@ function sectionLines(content, heading) {
|
|
|
15
15
|
if (start === -1) {
|
|
16
16
|
return { offset: 0, lines: [] };
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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]
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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) =>
|
|
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.
|
|
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
|
-
|
|
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');
|