plumbbob 0.10.1 → 0.10.3

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.10.1",
4
+ "version": "0.10.3",
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
@@ -152,7 +152,7 @@ a Claude Code built-in, so these docs write the full form.)
152
152
  nothing else:
153
153
 
154
154
  | Skill <img alt="" width="110" height="1"> | Does |
155
- |------------------------------------|------|
155
+ | ------------------------------------ | ------ |
156
156
  | `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps** |
157
157
  | `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3` self-approves through step 3, then pauses |
158
158
  | `/plumbbob:finish` | finish up: write the report, make the final commit, clear for a fresh goal |
@@ -160,7 +160,7 @@ nothing else:
160
160
  **Plan-shaping moves**: optional, for when the plan needs work mid-flight:
161
161
 
162
162
  | Skill <img alt="" width="110" height="1"> | Does |
163
- |------------------------------------|------|
163
+ | ------------------------------------ | ------ |
164
164
  | `/plumbbob:step` | revise/sharpen the next step (empty input auto-syncs it to reality) |
165
165
  | `/plumbbob:refine` | attack the frame for holes, or repair a drifted plan |
166
166
  | `/plumbbob:spike` | throwaway worktree experiment for a fork the plan can't settle |
@@ -168,7 +168,7 @@ nothing else:
168
168
  **Helpers**: orient, verify, recover, diagnose:
169
169
 
170
170
  | Skill <img alt="" width="110" height="1"> | Does |
171
- |------------------------------------|------|
171
+ | ------------------------------------ | ------ |
172
172
  | `/plumbbob:status` | orient: where you are, the next step's done-when and seam, and the next move |
173
173
  | `/plumbbob:verify` | the tick, standalone: check → self-review → validate → **PAUSE** → checkpoint, for a diff `/plumbbob:build` didn't write |
174
174
  | `/plumbbob:revert` | rewind: `git reset --hard` to a recorded checkpoint |
@@ -179,7 +179,7 @@ nothing else:
179
179
  **Capture**: the park/harvest loop for mid-build ideas:
180
180
 
181
181
  | Skill <img alt="" width="110" height="1"> | Does |
182
- |------------------------------------|------|
182
+ | ------------------------------------ | ------ |
183
183
  | `/plumbbob:park` | capture a mid-build idea without chasing it |
184
184
  | `/plumbbob:harvest` | triage parked ideas between steps (blocker / tangent / pivot) |
185
185
 
package/dist/cli-core.js CHANGED
@@ -58,8 +58,15 @@ const VERBS = [
58
58
  {
59
59
  name: 'status',
60
60
  description: 'print the orientation dashboard, or NO ACTIVE SESSION',
61
- synopsis: ['status [--build <slug>]'],
62
- flags: [BUILD_FLAG],
61
+ synopsis: ['status [--build <slug>] [--invoked "<args>"]'],
62
+ flags: [
63
+ BUILD_FLAG,
64
+ {
65
+ name: '--invoked',
66
+ value: '"<args>"',
67
+ gloss: 'the raw skill invocation; an explicit step number in it repoints the dashboard at that step',
68
+ },
69
+ ],
63
70
  notes: 'Read-only; always exits 0. With no cursor it lists the available builds.',
64
71
  },
65
72
  {
@@ -10,6 +10,12 @@
10
10
  * Append `line` after the last non-blank line of the `## <heading>` section:
11
11
  * that is, just before the next `## ` heading or EOF.
12
12
  *
13
+ * When `line` is a list item and the line above it is prose (the Park list's
14
+ * guidance blockquote, the Log's instructions paragraph), a blank line goes in
15
+ * first. Markdown wants lists separated from prose (MD032), and a consumer
16
+ * repo that lints `.plumbbob/**` turns the missing blank into a red gate at
17
+ * the first park or checkpoint of a build.
18
+ *
13
19
  * Returns null when the section is absent so the caller can report rather than
14
20
  * silently corrupt the doc.
15
21
  */
@@ -29,9 +35,19 @@ export function appendToSection(content, heading, line) {
29
35
  insertAt = i + 1;
30
36
  }
31
37
  }
32
- lines.splice(insertAt, 0, line);
38
+ const prev = lines[insertAt - 1] ?? '';
39
+ const needsBlank = isListLine(line) && prev.trim() !== '' && !isListLine(prev);
40
+ lines.splice(insertAt, 0, ...(needsBlank ? ['', line] : [line]));
33
41
  return lines.join('\n');
34
42
  }
43
+ /**
44
+ * A markdown list line: `-`, `*`, or `+` after optional indent. Every caller
45
+ * of appendToSection appends one of these, so the blank-line rule above keys
46
+ * on the line it lands after, not on which verb is writing.
47
+ */
48
+ function isListLine(line) {
49
+ return /^\s*[-*+]\s/.test(line);
50
+ }
35
51
  /**
36
52
  * Replace the `**Current step:**` line with `**Current step:** <label>`:
37
53
  * `build` sets it to the step's number and title joined by an em dash when a
@@ -159,21 +159,70 @@ export function lastLedgerSha(checkpoints) {
159
159
  }
160
160
  return last;
161
161
  }
162
+ /**
163
+ * The step number an explicit skill invocation asks for, out of the raw
164
+ * argument text `status --invoked` receives.
165
+ *
166
+ * The first whitespace-separated token shaped like `22` or `22-24` names the
167
+ * step (a range asks to start at its first number); flags such as `--auto` and
168
+ * anything else parse to null, meaning "no explicit ask", which leaves the
169
+ * dashboard's own suggestion in charge. Null in means null out, so a host that
170
+ * never substitutes the invocation degrades to today's rendering.
171
+ */
172
+ export function parseRequestedStep(raw) {
173
+ if (raw === null) {
174
+ return null;
175
+ }
176
+ for (const token of raw.trim().split(/\s+/)) {
177
+ const m = /^(\d+)(?:-\d+)?$/.exec(token);
178
+ if (m) {
179
+ const n = Number(m[1]);
180
+ return n >= 1 ? n : null;
181
+ }
182
+ }
183
+ return null;
184
+ }
162
185
  /**
163
186
  * The single primary next move the dashboard suggests.
164
187
  *
165
188
  * It suggests; the dashboard prints the full list + counts so the human can
166
- * always override. The phase is derived: a spike in progress and an in-flight
167
- * step each have one obvious next move; otherwise you are at the boundary and
168
- * the move follows from the steps.
189
+ * always override. An explicitly requested step outranks the derived
190
+ * suggestion: the request IS the human's override, so the move names that step
191
+ * (noting anything it skips or collides with) rather than arguing for the
192
+ * default. Otherwise the phase decides: a spike in progress and an in-flight
193
+ * step each have one obvious next move; else you are at the boundary and the
194
+ * move follows from the steps.
169
195
  */
170
- function nextMove(spiking, steps, inFlight, parked) {
196
+ function nextMove(spiking, steps, inFlight, parked, requested) {
197
+ const target = requested === null ? undefined : steps.find((s) => s.n === requested);
198
+ if (requested !== null && target === undefined) {
199
+ return `step ${requested} is not in the plan (${steps.length} step${steps.length === 1 ? '' : 's'} planned) — report the mismatch rather than guess`;
200
+ }
171
201
  if (spiking) {
172
- return 'close the spike — `plumbbob spike done`';
202
+ const tail = target === undefined ? '' : `; then build step ${target.n} (explicitly requested)`;
203
+ return `close the spike — \`plumbbob spike done\`${tail}`;
173
204
  }
174
205
  if (inFlight !== null) {
206
+ if (target !== undefined && target.n !== inFlight) {
207
+ return `build step ${target.n} — explicitly requested (step ${inFlight} is still in flight: \`/plumbbob:verify\` it or \`/plumbbob:abandon\` it first)`;
208
+ }
175
209
  return `finish step ${inFlight} — \`/plumbbob:verify\` (or keep editing, then \`/plumbbob:verify\`)`;
176
210
  }
211
+ if (target !== undefined) {
212
+ if (target.done) {
213
+ return `build step ${target.n} — explicitly requested (already checkpointed)`;
214
+ }
215
+ const skipped = steps.filter((s) => !s.done && s.n < target.n).length;
216
+ const notes = [];
217
+ if (skipped > 0) {
218
+ notes.push(`skips ${skipped} undone step${skipped === 1 ? '' : 's'}`);
219
+ }
220
+ if (!target.planned) {
221
+ notes.push('still unplanned: `/plumbbob:step` it first');
222
+ }
223
+ const suffix = notes.length > 0 ? ` (${notes.join('; ')})` : '';
224
+ return `build step ${target.n} — explicitly requested${suffix}`;
225
+ }
177
226
  // At the boundary (DESIGN): the move follows from the steps.
178
227
  const nextUndone = steps.find((s) => !s.done);
179
228
  if (nextUndone === undefined) {
@@ -195,8 +244,13 @@ function nextMove(spiking, steps, inFlight, parked) {
195
244
  export function orient(input) {
196
245
  const steps = parseSteps(input.intent);
197
246
  const parked = parseParked(input.buildLog);
198
- const nextUndone = steps.find((s) => !s.done);
199
- const seamParse = nextUndone === undefined ? null : parseStepSeam(input.intent, nextUndone.n);
247
+ // The target the dashboard details: the explicitly requested step when it
248
+ // exists in the plan, else the next undone. A requested number that names no
249
+ // planned step carries nothing here; the next-move line reports the mismatch
250
+ // while the rest of the dashboard renders as usual.
251
+ const requestedStep = input.requested === null ? undefined : steps.find((s) => s.n === input.requested);
252
+ const target = requestedStep ?? steps.find((s) => !s.done);
253
+ const seamParse = target === undefined ? null : parseStepSeam(input.intent, target.n);
200
254
  const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
201
255
  return {
202
256
  title: parseTitle(input.intent),
@@ -205,10 +259,11 @@ export function orient(input) {
205
259
  lastCheckpoint: parseLastCheckpoint(input.checkpoints),
206
260
  parked,
207
261
  openQuestions: parseOpenQuestions(input.intent),
208
- next: nextMove(input.spiking, steps, input.inFlight, parked),
209
- nextDoneWhen: nextUndone?.doneWhen ?? null,
262
+ next: nextMove(input.spiking, steps, input.inFlight, parked, input.requested),
263
+ requested: requestedStep?.n ?? null,
264
+ nextDoneWhen: target?.doneWhen ?? null,
210
265
  nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
211
- nextModel: nextUndone?.model ?? null,
266
+ nextModel: target?.model ?? null,
212
267
  outOfBand: input.outOfBand,
213
268
  };
214
269
  }
@@ -218,14 +273,19 @@ export function orient(input) {
218
273
  export function formatOrientation(o) {
219
274
  const doneCount = o.steps.filter((s) => s.done).length;
220
275
  const nextUndone = o.steps.find((s) => !s.done);
276
+ // One arrow, always: an explicitly requested step takes the marker and the
277
+ // detail rows, so the injected state never argues with the invocation. With
278
+ // no (valid) request the next undone step keeps them, as ever.
279
+ const requestedStep = o.requested === null ? undefined : o.steps.find((s) => s.n === o.requested);
280
+ const marked = requestedStep ?? nextUndone;
221
281
  const stepLines = o.steps.map((s) => {
222
- const marker = s.done ? '✓' : s === nextUndone ? '▸' : ' ';
223
- const tail = s === nextUndone ? ' ← next' : '';
282
+ const marker = s.done ? '✓' : s === marked ? '▸' : ' ';
283
+ const tail = s === marked ? (requestedStep === undefined ? ' ← next' : ' ← requested') : '';
224
284
  const head = ` ${marker} ${s.n} ${s.title}${tail}`;
225
- if (s !== nextUndone) {
285
+ if (s !== marked) {
226
286
  return head;
227
287
  }
228
- // Surface the next step's detail so the human can review it (and `/plumbbob:step`-
288
+ // Surface the target step's detail so the human can review it (and `/plumbbob:step`-
229
289
  // revise) before building. Only what's present: a rough step shows neither.
230
290
  const detail = [];
231
291
  if (o.nextDoneWhen !== null) {
@@ -40,13 +40,14 @@ export function build(cwd, args) {
40
40
  return 1;
41
41
  }
42
42
  const intent = readFileSync(intentPath(root, slug), 'utf8');
43
+ const steps = parseSteps(intent);
43
44
  // No argument ⇒ enter the next undone step in intent.md (the same idiom
44
45
  // `checkpoint` uses), so a bare `plumbbob build` advances the loop without the
45
46
  // skill re-deriving the step in prose. Every step checkpointed ⇒ a `/plumbbob:step`
46
47
  // nudge, not a silent no-op.
47
48
  let step;
48
49
  if (raw === undefined) {
49
- const nextUndone = parseSteps(intent).find((s) => !s.done);
50
+ const nextUndone = steps.find((s) => !s.done);
50
51
  if (nextUndone === undefined) {
51
52
  process.stderr.write('plumbbob: no undone step to build — every planned step is checkpointed. `/plumbbob:step` to add an increment, or `/plumbbob:finish`.\n');
52
53
  return 1;
@@ -73,9 +74,17 @@ export function build(cwd, args) {
73
74
  // The build-log's top half is CLI-owned so it never lies: show this step in
74
75
  // flight: Current step plus the ☐/☑ mirror, re-rendered from intent.md.
75
76
  // Best-effort: a missing or hand-edited build-log never blocks the build.
76
- const title = parseSteps(intent).find((s) => s.n === step)?.title ?? null;
77
+ const title = steps.find((s) => s.n === step)?.title ?? null;
77
78
  syncBuildLogState(root, slug, stepLabel(step, title));
78
- const picked = raw === undefined ? ' (next undone)' : '';
79
+ // An explicit jump past undone work says so out loud: the entered step and
80
+ // the count it skips read back to the human (and to the transcript), so a
81
+ // deliberate `build 22` and a confused one look different on the page.
82
+ const skipped = raw === undefined ? 0 : steps.filter((s) => !s.done && s.n < step).length;
83
+ const picked = raw === undefined
84
+ ? ' (next undone)'
85
+ : skipped > 0
86
+ ? ` (explicitly requested; skips ${skipped} undone step${skipped === 1 ? '' : 's'})`
87
+ : '';
79
88
  process.stdout.write(`plumbbob: building step ${step}${picked}. Seam (for orientation; not a lock):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
80
89
  return 0;
81
90
  }
@@ -140,7 +140,7 @@ function appendStats(root, slug) {
140
140
  '## Stats',
141
141
  '',
142
142
  '| step | red checks | drift warnings | reverts | wall-clock |',
143
- '|------|------------|----------------|---------|------------|',
143
+ '| ---- | ---------- | -------------- | ------- | ---------- |',
144
144
  ...rows,
145
145
  `| **total** | ${totals.redChecks} | ${totals.driftWarnings} | ${totals.reverts} | ${formatWall(totals.wallMs > 0 ? totals.wallMs : null)} |`,
146
146
  '',
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { commitsSince, findRepoRoot } from "../lib/git.js";
10
10
  import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
11
11
  import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
12
- import { formatOrientation, lastLedgerSha, orient } from "../lib/orient.js";
12
+ import { formatOrientation, lastLedgerSha, orient, parseRequestedStep } from "../lib/orient.js";
13
13
  /**
14
14
  * Read a file as utf8, or '' when it is missing or unreadable.
15
15
  */
@@ -21,12 +21,31 @@ function readOr(path) {
21
21
  return '';
22
22
  }
23
23
  }
24
+ /**
25
+ * Split `--invoked "<raw args>"` out of the argument list, ahead of any other
26
+ * flag parsing.
27
+ *
28
+ * The value is the verbatim text of a skill invocation (the `$ARGUMENTS` the
29
+ * `/plumbbob:build` skill passes through), so it can contain anything,
30
+ * including tokens that look like this CLI's own flags; extracting it first
31
+ * keeps such a value from confusing `resolveBuild`. A missing flag, or one
32
+ * with no value, reads as null: no explicit ask.
33
+ */
34
+ function takeInvoked(args) {
35
+ const i = args.indexOf('--invoked');
36
+ if (i === -1)
37
+ return { invoked: null, rest: args };
38
+ return { invoked: args[i + 1] ?? null, rest: [...args.slice(0, i), ...args.slice(i + 2)] };
39
+ }
24
40
  /**
25
41
  * Print the orientation dashboard for the active build, plus its harness bindings.
26
42
  *
27
43
  * No session prints the exact `NO ACTIVE SESSION` sentinel; a session with
28
44
  * builds but no active one lists them and points at `use`. Always exits 0:
29
- * status is a report, never a gate.
45
+ * status is a report, never a gate. With `--invoked "<raw args>"` (how the
46
+ * `/plumbbob:build` skill passes its own invocation through), an explicit step
47
+ * number in the raw text repoints the dashboard's marker, detail, and next
48
+ * move at that step, so the injected state never contradicts the human's ask.
30
49
  */
31
50
  export function status(cwd, args = []) {
32
51
  const root = findRepoRoot(cwd);
@@ -34,10 +53,11 @@ export function status(cwd, args = []) {
34
53
  process.stdout.write('NO ACTIVE SESSION\n');
35
54
  return 0;
36
55
  }
56
+ const { invoked, rest } = takeInvoked(args);
37
57
  // A session with builds but no resolvable cursor (finish cleared it, or the repo
38
58
  // holds several builds and none is active) has no single dashboard to show, so
39
59
  // list the builds and point at `use` instead of rendering a broken, empty one.
40
- const { build: slug } = resolveBuild(root, args);
60
+ const { build: slug } = resolveBuild(root, rest);
41
61
  if (slug === null) {
42
62
  const builds = listBuilds(root);
43
63
  if (builds.length > 0) {
@@ -59,6 +79,7 @@ export function status(cwd, args = []) {
59
79
  checkpoints,
60
80
  inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
61
81
  spiking: inSpike(root, slug),
82
+ requested: parseRequestedStep(invoked),
62
83
  outOfBand: anchor === null ? 0 : commitsSince(root, anchor),
63
84
  });
64
85
  const lines = [formatOrientation(orientation), ...harnessSection(root, slug)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Attention-first build process: 14 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -53,6 +53,7 @@
53
53
  "@types/node": "26.2.0",
54
54
  "@vitest/coverage-v8": "4.1.10",
55
55
  "@vvago/vale": "3.17.1",
56
+ "cspell": "10.0.1",
56
57
  "fallow": "3.14.0",
57
58
  "fascicle": "0.8.16",
58
59
  "markdownlint-cli2": "0.23.2",
@@ -8,7 +8,14 @@ allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob build:*
8
8
 
9
9
  # PlumbBob: build a step (the default engine)
10
10
 
11
- Current session state (injected when this skill runs): !`plumbbob status 2>/dev/null || echo "plumbbob CLI not on PATH in this session. Marketplace install: confirm the plugin is enabled in /plugin, then /reload-plugins. Skills-dir/global install: npm i -g plumbbob && plumbbob init."`
11
+ Current session state (injected when this skill runs): !`plumbbob status --invoked "$ARGUMENTS" 2>/dev/null || echo "plumbbob CLI not on PATH in this session. Marketplace install: confirm the plugin is enabled in /plugin, then /reload-plugins. Skills-dir/global install: npm i -g plumbbob && plumbbob init."`
12
+
13
+ **The invocation outranks the injected state.** The dashboard above is computed from
14
+ the plan; the arguments you were invoked with are the human's explicit ask. When they
15
+ name a step, build that step, whatever any `next →` line suggests. The CLI already
16
+ repoints the dashboard at an explicit step number when it can see one (`--invoked`),
17
+ so a surviving mismatch means the invocation text never reached it, and the typed
18
+ number still wins.
12
19
 
13
20
  This is the **bundled executor**: the default engine, not the only one. It is
14
21
  **swappable**: you can implement any step by hand, in a vibe session, or with
@@ -36,11 +36,11 @@ check green + checkpoint taken, via `/plumbbob:verify` or `/plumbbob:build`.)*
36
36
  Classify each parked item as exactly ONE. Naming it before acting is what keeps you
37
37
  from sprawling across branches.
38
38
 
39
- | Class | Meaning | Action |
40
- |------------------|-------------------------------------------|---------------------------------|
41
- | **blocker** | Plan was wrong/incomplete; can't proceed | `/plumbbob:revert`, fold into intent |
42
- | **tangent** | A different path, not clearly better | Defer or kill. Default here. |
43
- | **pivot signal** | Evidence the whole approach is wrong | Stop. Replan deliberately. |
39
+ | Class | Meaning | Action |
40
+ | ---------------- | ----------------------------------------- | ------------------------------------ |
41
+ | **blocker** | Plan was wrong/incomplete; can't proceed | `/plumbbob:revert`, fold into intent |
42
+ | **tangent** | A different path, not clearly better | Defer or kill. Default here. |
43
+ | **pivot signal** | Evidence the whole approach is wrong | Stop. Replan deliberately. |
44
44
 
45
45
  > Reality check: almost everything that *feels* like a pivot is a tangent. Require a
46
46
  > failed assumption, not a shinier idea, before you pivot.