plumbbob 0.8.18 → 0.9.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 (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +50 -44
  3. package/dist/cli-core.js +35 -15
  4. package/dist/lib/agents.js +230 -112
  5. package/dist/lib/buildlog.js +50 -30
  6. package/dist/lib/buildlogsync.js +21 -12
  7. package/dist/lib/check.js +69 -35
  8. package/dist/lib/commitmsg.js +53 -32
  9. package/dist/lib/git.js +86 -31
  10. package/dist/lib/intent.js +91 -31
  11. package/dist/lib/latch.js +70 -39
  12. package/dist/lib/orient.js +78 -39
  13. package/dist/lib/plugins.js +11 -8
  14. package/dist/lib/settings.js +65 -32
  15. package/dist/lib/sidecar.js +320 -150
  16. package/dist/lib/templates.js +17 -6
  17. package/dist/verbs/agent.js +116 -55
  18. package/dist/verbs/build.js +27 -12
  19. package/dist/verbs/check.js +33 -7
  20. package/dist/verbs/checkpoint.js +153 -78
  21. package/dist/verbs/doctor.js +192 -95
  22. package/dist/verbs/finish.js +86 -45
  23. package/dist/verbs/handoff.js +56 -20
  24. package/dist/verbs/init.js +37 -12
  25. package/dist/verbs/park.js +14 -3
  26. package/dist/verbs/revert.js +64 -24
  27. package/dist/verbs/spike.js +89 -30
  28. package/dist/verbs/start.js +72 -38
  29. package/dist/verbs/status.js +44 -17
  30. package/dist/verbs/turn.js +88 -45
  31. package/dist/verbs/use.js +20 -9
  32. package/package.json +1 -1
  33. package/skills/{pb-build → build}/SKILL.md +25 -25
  34. package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
  35. package/skills/{pb-finish → finish}/SKILL.md +3 -3
  36. package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
  37. package/skills/{pb-park → park}/SKILL.md +2 -2
  38. package/skills/{pb-plan → plan}/SKILL.md +11 -11
  39. package/skills/{pb-refine → refine}/SKILL.md +7 -7
  40. package/skills/{pb-revert → revert}/SKILL.md +2 -2
  41. package/skills/{pb-spike → spike}/SKILL.md +4 -4
  42. package/skills/{pb-status → status}/SKILL.md +3 -3
  43. package/skills/{pb-step → step}/SKILL.md +9 -9
  44. package/skills/{pb-verify → verify}/SKILL.md +8 -8
  45. package/templates/build-log.md +7 -7
  46. package/templates/intent.md +8 -8
  47. package/templates/spike-report.md +1 -1
@@ -1,10 +1,12 @@
1
- // Orientation model for `status` (D8/D15): parse a live session into the
2
- // dashboard the human reads to know where they are and what to do next. Pure and
3
- // best-effort — it takes raw file contents (no fs), and a malformed doc degrades
4
- // to fewer fields rather than throwing. Functional/procedural, no classes, no
5
- // default export (C1).
1
+ // The orientation model behind `status`: parse a live build session into the
2
+ // where-am-I dashboard — title, phase, step list, last checkpoint, counts, and
3
+ // one suggested next move. Pure and best-effort by design: it takes raw file
4
+ // contents (no fs), and a malformed document degrades to fewer fields rather
5
+ // than throwing.
6
6
  import { parseStepSeam } from "./intent.js";
7
- // The lines of a named `## Section`, from its heading to the next `## ` (or EOF).
7
+ /**
8
+ * The lines of a named `## Section`, from its heading to the next `## ` (or EOF).
9
+ */
8
10
  function sectionLines(content, heading) {
9
11
  const lines = content.split('\n');
10
12
  const start = lines.findIndex((l) => l.trim() === heading);
@@ -17,6 +19,9 @@ function sectionLines(content, heading) {
17
19
  }
18
20
  return lines.slice(start + 1, end);
19
21
  }
22
+ /**
23
+ * The build title — the first `# ` heading in intent.md, or null when absent.
24
+ */
20
25
  export function parseTitle(intent) {
21
26
  for (const line of intent.split('\n')) {
22
27
  const m = /^#\s+(.+?)\s*$/.exec(line);
@@ -26,9 +31,14 @@ export function parseTitle(intent) {
26
31
  }
27
32
  return null;
28
33
  }
29
- // Steps under `## Steps`: `N. [ |x] Title — **done when:** ...`. The title is the
30
- // text up to the first em dash; `planned` is true when the step's block carries a
31
- // `done when` criterion. Roadmap prose lives in its own section, never here (D6).
34
+ /**
35
+ * Parse the steps under `## Steps`: `N. [ |x] Title — **done when:** ...`.
36
+ *
37
+ * The title is the text up to the first em dash; `planned` is true when the
38
+ * step's block carries a `done when` criterion. Only `## Steps` is machine-read
39
+ * as the build plan — narrative roadmap prose lives in its own section and
40
+ * never lands here.
41
+ */
32
42
  export function parseSteps(intent) {
33
43
  const lines = sectionLines(intent, '## Steps');
34
44
  const starts = [];
@@ -54,9 +64,12 @@ export function parseSteps(intent) {
54
64
  return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model };
55
65
  });
56
66
  }
57
- // Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section — mechanical
58
- // bookkeeping for `checkpoint` so `status` reflects a checkpointed step. A no-op if
59
- // the step is absent or already done.
67
+ /**
68
+ * Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section.
69
+ *
70
+ * Mechanical bookkeeping for `checkpoint`, so `status` reflects a checkpointed
71
+ * step. A no-op if the step is absent or already done.
72
+ */
60
73
  export function markStepDone(intent, n) {
61
74
  let inSteps = false;
62
75
  return intent
@@ -73,18 +86,31 @@ export function markStepDone(intent, n) {
73
86
  })
74
87
  .join('\n');
75
88
  }
76
- // Open questions still open: `- Q\d+:` openers that do not say "resolved". The
77
- // opener may carry a slug-at-birth gloss — `- Q2 (some-slug): ...` (D3) — which
78
- // the count reads through; sub-lines (`*plain:*`/`*lean:*`) never match.
89
+ /**
90
+ * Count the questions still open: `- Q\d+:` openers under `## Open questions`
91
+ * that do not say "resolved".
92
+ *
93
+ * The opener may carry a slug-at-birth gloss — `- Q2 (some-slug): ...` — which
94
+ * the count reads through; sub-lines (`*plain:*`/`*lean:*`) never match.
95
+ */
79
96
  export function parseOpenQuestions(intent) {
80
97
  return sectionLines(intent, '## Open questions').filter((l) => /^- Q\d+(?: \([^)]+\))?:/.test(l.trim()) && !/resolved/i.test(l)).length;
81
98
  }
82
- // Open parked items: `- [ ]` lines under `## Park list` (the `park` verb's format).
83
- // A harvested item is flipped to `- [x]` by `/pb-harvest` and no longer counts; the
84
- // `(none yet)` placeholder and the blockquote instructions never match.
99
+ /**
100
+ * Count the open parked items: `- [ ]` lines under `## Park list`.
101
+ *
102
+ * A parked item is a mid-build idea the `park` verb appends as a flat checklist
103
+ * line for later triage; `/plumbbob:harvest` flips a triaged one to `- [x]` and it
104
+ * stops counting. The `(none yet)` placeholder and the blockquote instructions
105
+ * never match.
106
+ */
85
107
  export function parseParked(buildLog) {
86
108
  return sectionLines(buildLog, '## Park list').filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
87
109
  }
110
+ /**
111
+ * The last `step N <sha>` line in the checkpoints ledger, or null when no step
112
+ * has landed yet.
113
+ */
88
114
  export function parseLastCheckpoint(checkpoints) {
89
115
  let last = null;
90
116
  for (const line of checkpoints.split('\n')) {
@@ -95,12 +121,15 @@ export function parseLastCheckpoint(checkpoints) {
95
121
  }
96
122
  return last;
97
123
  }
98
- // The SHA of the last ledger line of ANY kind — baseline, plan, or step. This is
99
- // the reconciliation anchor for the out-of-band count (D66): a commit that lands
100
- // after the plan but before the first step checkpoint is exactly the window the
101
- // receipt exists for, so anchoring on step lines alone would leave it invisible.
102
- // `parseLastCheckpoint` stays step-only — it feeds the "last checkpoint step N"
103
- // display, where baseline/plan would be noise.
124
+ /**
125
+ * The SHA of the last ledger line of ANY kind — baseline, plan, or step.
126
+ *
127
+ * This is the reconciliation anchor for the out-of-band commit count: a commit
128
+ * that lands after the plan but before the first step checkpoint is exactly the
129
+ * window the reconciliation line exists for, so anchoring on step lines alone
130
+ * would leave it invisible. `parseLastCheckpoint` stays step-only — it feeds
131
+ * the "last checkpoint step N" display, where baseline/plan would be noise.
132
+ */
104
133
  export function lastLedgerSha(checkpoints) {
105
134
  let last = null;
106
135
  for (const line of checkpoints.split('\n')) {
@@ -111,32 +140,39 @@ export function lastLedgerSha(checkpoints) {
111
140
  }
112
141
  return last;
113
142
  }
114
- // The single primary next move (D15). It suggests; the dashboard prints the full
115
- // list + counts so the human can always override. The phase is derived: a spike
116
- // in progress and an in-flight step each have one obvious next move; otherwise you
117
- // are at the boundary and the move follows from the steps.
143
+ /**
144
+ * The single primary next move the dashboard suggests.
145
+ *
146
+ * It suggests; the dashboard prints the full list + counts so the human can
147
+ * always override. The phase is derived: a spike in progress and an in-flight
148
+ * step each have one obvious next move; otherwise you are at the boundary and
149
+ * the move follows from the steps.
150
+ */
118
151
  function nextMove(spiking, steps, inFlight, parked) {
119
152
  if (spiking) {
120
153
  return 'close the spike — `plumbbob spike done`';
121
154
  }
122
155
  if (inFlight !== null) {
123
- return `finish step ${inFlight} — \`/pb-verify\` (or keep editing, then \`/pb-verify\`)`;
156
+ return `finish step ${inFlight} — \`/plumbbob:verify\` (or keep editing, then \`/plumbbob:verify\`)`;
124
157
  }
125
158
  // At the boundary (DESIGN): the move follows from the steps.
126
159
  const nextUndone = steps.find((s) => !s.done);
127
160
  if (nextUndone === undefined) {
128
161
  if (steps.length === 0) {
129
- return 'plan the first step — `/pb-step`';
162
+ return 'plan the first step — `/plumbbob:step`';
130
163
  }
131
164
  // Batch-default: the steps were planned up front, so finishing them usually
132
- // means "finish up" — but `/pb-step` can still add an increment if reality grew.
133
- const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/pb-harvest\`; then ` : '';
134
- return `${harvest}finish up — \`/pb-finish\` (or \`/pb-step\` to add another increment)`;
165
+ // means "finish up" — but `/plumbbob:step` can still add an increment if reality grew.
166
+ const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/plumbbob:harvest\`; then ` : '';
167
+ return `${harvest}finish up — \`/plumbbob:finish\` (or \`/plumbbob:step\` to add another increment)`;
135
168
  }
136
169
  return nextUndone.planned
137
- ? `build step ${nextUndone.n} — \`/pb-build\` (or \`/pb-step\` to revise it first)`
138
- : `plan step ${nextUndone.n} — \`/pb-step\``;
170
+ ? `build step ${nextUndone.n} — \`/plumbbob:build\` (or \`/plumbbob:step\` to revise it first)`
171
+ : `plan step ${nextUndone.n} — \`/plumbbob:step\``;
139
172
  }
173
+ /**
174
+ * Assemble the full orientation from a build's raw documents and marker state.
175
+ */
140
176
  export function orient(input) {
141
177
  const steps = parseSteps(input.intent);
142
178
  const parked = parseParked(input.buildLog);
@@ -157,6 +193,9 @@ export function orient(input) {
157
193
  outOfBand: input.outOfBand,
158
194
  };
159
195
  }
196
+ /**
197
+ * Render an Orientation as the plain-text dashboard `status` prints.
198
+ */
160
199
  export function formatOrientation(o) {
161
200
  const doneCount = o.steps.filter((s) => s.done).length;
162
201
  const nextUndone = o.steps.find((s) => !s.done);
@@ -167,7 +206,7 @@ export function formatOrientation(o) {
167
206
  if (s !== nextUndone) {
168
207
  return head;
169
208
  }
170
- // Surface the next step's detail so the human can review it (and `/pb-step`-
209
+ // Surface the next step's detail so the human can review it (and `/plumbbob:step`-
171
210
  // revise) before building. Only what's present — a rough step shows neither.
172
211
  const detail = [];
173
212
  if (o.nextDoneWhen !== null) {
@@ -184,9 +223,9 @@ export function formatOrientation(o) {
184
223
  const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
185
224
  const cp = o.lastCheckpoint;
186
225
  const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
187
- // A neutral reconciliation note (D66), only when there is something to reconcile:
188
- // commits landed since the last checkpoint that plumbbob's ledger didn't record.
189
- // Informational — the human commits freely (repo D3/C5), so this never gates.
226
+ // A neutral reconciliation note, only when there is something to reconcile:
227
+ // commits landed since the last checkpoint that plumbbob's ledger didn't
228
+ // record. Informational — the human commits freely, so this never gates.
190
229
  const receipts = o.outOfBand > 0
191
230
  ? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
192
231
  : [];
@@ -1,14 +1,17 @@
1
1
  // Detect a marketplace-installed plumbbob plugin from Claude Code's plugin state.
2
- // Used to keep the skills-dir install (`plumbbob init`) from colliding with a
3
- // marketplace install: two plugins both named `plumbbob` fight over the
4
- // `/plumbbob:*` namespace and the skills can drop to flat names (`/pb-status`).
5
- // Read-only, node builtins only (C1/C2): a missing / unreadable / malformed file
6
- // means "none installed".
2
+ // Keeps the skills-dir install (`plumbbob init`) from colliding with a marketplace
3
+ // install: two plugins both named `plumbbob` fight over the `/plumbbob:*` namespace
4
+ // and the skills can drop to flat names (`/status`). Read-only, node builtins
5
+ // only: a missing, unreadable, or malformed file means "none installed".
7
6
  import { readFileSync } from 'node:fs';
8
7
  import { join } from 'node:path';
9
- // Marketplace plugin ids for plumbbob recorded in installed_plugins.json
10
- // (e.g. "plumbbob@robmclarty"). Empty when none, or the file is absent/unreadable.
11
- // `plumbbob@<marketplace>` matches; `plumbbob-spike@x` and the like do not.
8
+ /**
9
+ * Marketplace plugin ids for plumbbob recorded in installed_plugins.json.
10
+ *
11
+ * Ids look like "plumbbob@robmclarty": a bare `plumbbob` or a
12
+ * `plumbbob@<marketplace>` id matches; `plumbbob-spike@x` and the like do not.
13
+ * Empty when none are installed, or the file is absent, unreadable, or malformed.
14
+ */
12
15
  export function marketplacePlumbbob(home) {
13
16
  try {
14
17
  const raw = readFileSync(join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8');
@@ -1,29 +1,42 @@
1
- // The settings ladder (D27): a resolved setting comes from, in priority order,
1
+ // The settings ladder: a resolved setting comes from, in priority order,
2
2
  // 1. a CLI flag — passed in by the verb (undefined when absent)
3
- // 2. settings.local.json — untracked personal overlay + per-worktree cursor
3
+ // 2. settings.local.json — untracked personal overlay, per-worktree
4
4
  // 3. settings.json — tracked project defaults
5
5
  // 4. a built-in default — supplied by the caller
6
6
  // The first defined rung wins. Both files are optional JSON; a missing or
7
7
  // malformed file contributes nothing rather than throwing, so a broken personal
8
- // overlay can never wedge the tool. Functional/procedural, node builtins (C1/C2).
8
+ // overlay can never wedge the tool. Functional and procedural, node builtins only.
9
9
  //
10
- // Known keys: `check` (string — the heavy gate, tracked in settings.json) and
11
- // `auto` (boolean — a personal preference, so it belongs in settings.local.json).
12
- // Since D67 `auto` is NOT a checkpoint grant: the latch no longer reads it to allow
13
- // a land (a model can write this file, and a grant it can forge is no grant), only to
14
- // note it at the pause; self-approval comes from the human's literal `/pb-build --auto`
15
- // or a range. The per-worktree active-build cursor is NOT here — it lives in the STATE
16
- // file (see sidecar.ts), so this overlay stays purely human-owned and the tool never
17
- // rewrites it.
10
+ // Known keys: `check` (string — the command for the heavy verify gate, a project
11
+ // default in settings.json) and `auto` (boolean — a personal preference, so it
12
+ // belongs in settings.local.json). `auto` is NOT a checkpoint grant: the approval
13
+ // latch never reads it to allow a land — a model can write this file, and a grant
14
+ // it can forge is no grant — only to name it at the pause when set; self-approval
15
+ // comes solely from the human's literal `/build --auto` or a typed step range.
16
+ // The per-worktree active-build cursor is NOT here — it lives in the STATE file
17
+ // (see sidecar.ts), which keeps this overlay a human-authored file rather than
18
+ // tool-managed state.
18
19
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
20
  import { dirname, join } from 'node:path';
20
- const DIRNAME = '.plumbbob';
21
+ const DIRNAME = '.plumbbob'; // the sidecar folder name at the repo root
22
+ /**
23
+ * The tracked project-defaults file: `.plumbbob/settings.json`.
24
+ */
21
25
  export function settingsPath(root) {
22
26
  return join(root, DIRNAME, 'settings.json');
23
27
  }
28
+ /**
29
+ * The untracked personal overlay: `.plumbbob/settings.local.json`.
30
+ */
24
31
  export function localSettingsPath(root) {
25
32
  return join(root, DIRNAME, 'settings.local.json');
26
33
  }
34
+ /**
35
+ * Read one settings file as a plain object.
36
+ *
37
+ * A missing, malformed, or non-object file yields {} — an empty rung that
38
+ * contributes nothing rather than wedging the tool.
39
+ */
27
40
  function readSettings(path) {
28
41
  try {
29
42
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
@@ -33,8 +46,12 @@ function readSettings(path) {
33
46
  return {};
34
47
  }
35
48
  }
36
- // The raw ladder: flag → local overlay → project defaults → undefined. The typed
37
- // helpers below apply the built-in default and reject wrong-typed rungs.
49
+ /**
50
+ * The raw ladder: flag → local overlay → project defaults → undefined.
51
+ *
52
+ * The typed helpers below apply the built-in default and reject wrong-typed
53
+ * rungs.
54
+ */
38
55
  function resolveSetting(root, key, flag) {
39
56
  if (flag !== undefined)
40
57
  return flag;
@@ -43,40 +60,56 @@ function resolveSetting(root, key, flag) {
43
60
  return local;
44
61
  return readSettings(settingsPath(root))[key];
45
62
  }
46
- // Resolve a string setting (e.g. `check`). A missing rung, or one holding a
47
- // non-string / blank value, yields the caller's fallback rather than gating on
48
- // garbage.
63
+ /**
64
+ * Resolve a string setting (e.g. `check`).
65
+ *
66
+ * A missing rung, or one holding a non-string / blank value, yields the
67
+ * caller's fallback rather than gating on garbage.
68
+ */
49
69
  export function resolveString(root, key, fallback, flag) {
50
70
  const value = resolveSetting(root, key, flag);
51
71
  return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
52
72
  }
53
- // Resolve a boolean setting (e.g. `auto`). A missing or non-boolean rung yields
54
- // the caller's fallback.
73
+ /**
74
+ * Resolve a boolean setting (e.g. `auto`). A missing or non-boolean rung
75
+ * yields the caller's fallback.
76
+ */
55
77
  export function resolveBoolean(root, key, fallback, flag) {
56
78
  const value = resolveSetting(root, key, flag);
57
79
  return typeof value === 'boolean' ? value : fallback;
58
80
  }
59
- // Resolve a non-negative integer setting (e.g. `agentTimeout`, D51). A missing
60
- // rung, or one holding a non-finite / negative / non-integer number, yields the
61
- // caller's fallback rather than a garbage timeout — 0 means "no timeout", so a
62
- // broken value must never silently become one.
81
+ /**
82
+ * Resolve a non-negative integer setting (e.g. `agentTimeout`).
83
+ *
84
+ * A missing rung, or one holding a non-finite / negative / non-integer number,
85
+ * yields the caller's fallback rather than a garbage timeout — 0 means "no
86
+ * timeout", so a broken value must never silently become one.
87
+ */
63
88
  export function resolveNumber(root, key, fallback, flag) {
64
89
  const value = resolveSetting(root, key, flag);
65
90
  return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
66
91
  }
67
- // Resolve an object-valued setting (e.g. the `agents` slot-binding defaults, D57)
68
- // across the same ladder. The first defined, object-typed rung wins; a missing
69
- // rung, or one holding a non-object / array / scalar, yields {} rather than
70
- // garbage — mirrors the string/boolean/number resolvers above. No flag rung: the
71
- // caller (`agent run`) merges these under the per-build harness, not over a flag.
92
+ /**
93
+ * Resolve an object-valued setting (e.g. the `agents` slot-binding defaults)
94
+ * across the same ladder.
95
+ *
96
+ * The first defined, object-typed rung wins; a missing rung, or one holding a
97
+ * non-object / array / scalar, yields {} rather than garbage — mirrors the
98
+ * string/boolean/number resolvers above. No flag rung: the caller (`agent
99
+ * run`) merges these under the per-build harness bindings, not over a flag.
100
+ */
72
101
  export function resolveRecord(root, key) {
73
102
  const value = resolveSetting(root, key, undefined);
74
103
  return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
75
104
  }
76
- // Merge one key into settings.local.json, preserving the other keys and creating
77
- // the file when absent. Pretty-printed so the overlay stays hand-editable. A
78
- // malformed existing file contributes nothing (readSettings yields {}) and is
79
- // overwritten with a clean object rather than throwing.
105
+ /**
106
+ * Merge one key into settings.local.json, preserving the other keys and
107
+ * creating the file when absent.
108
+ *
109
+ * Pretty-printed so the overlay stays hand-editable. A malformed existing file
110
+ * contributes nothing (readSettings yields {}) and is overwritten with a clean
111
+ * object rather than throwing.
112
+ */
80
113
  export function setLocalSetting(root, key, value) {
81
114
  const path = localSettingsPath(root);
82
115
  mkdirSync(dirname(path), { recursive: true });