plumbbob 0.10.1 → 0.10.2

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.2",
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/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
  {
@@ -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
  }
@@ -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.2",
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",
@@ -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