plumbbob 0.13.0 → 0.13.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.13.0",
4
+ "version": "0.13.1",
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
@@ -131,8 +131,8 @@ In Claude Code, inside any git repo with a clean tree:
131
131
 
132
132
  1. **Plan.** Fire `/plumbbob:plan` and give it whatever you have: nothing (it interviews
133
133
  you), a rough line (`/plumbbob:plan rate-limit POST /login, 5/min/IP, return 429`), or a
134
- path to a spec file. Together you fill the build's `intent.md`. No code is written
135
- yet.
134
+ path to a spec file. Together you fill the build's `intent.md`: the steps, and the
135
+ build ranges that say where the build stops for your call. No code is written yet.
136
136
  2. **Build.** Fire `/plumbbob:build`. It implements the next undone step, runs the
137
137
  heavy check gate, reviews its own diff against the plan, and stops. What it stops on
138
138
  is one block with a fixed shape (the [turn anatomy](docs/presentation.md)): the CLI
@@ -198,7 +198,9 @@ In Claude Code, inside any git repo with a clean tree:
198
198
  whole block again, since you have already read it; say `show the pause` to see the
199
199
  block again. Direct it and it takes that as the fix, and nothing lands until you say
200
200
  `looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
201
- *is* the clock tick. Whenever you lose the thread, `/plumbbob:status` shows where you
201
+ *is* the clock tick. When the plan cut build ranges, the line beneath Next Up names
202
+ one to type instead (`· range 2 of 2: /plumbbob:build 2-3`): it lands each step it
203
+ names and stops where the plan reserved a call for you. Whenever you lose the thread, `/plumbbob:status` shows where you
202
204
  are and names the next move.
203
205
  4. **Finish.** When the last step is done, `/plumbbob:finish` writes a report of what shipped
204
206
  and why into the build's tracked folder, makes the final commit, and clears the slate
@@ -224,8 +226,8 @@ nothing else:
224
226
 
225
227
  | Skill <img alt="" width="110" height="1"> | Does |
226
228
  | ------------------------------------ | ------ |
227
- | `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps** |
228
- | `/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 stops |
229
+ | `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps**, with the build ranges that say where the build stops for you |
230
+ | `/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`, or a list like `20,5-9`, self-approves exactly the steps it names, then stops |
229
231
  | `/plumbbob:finish` | finish up: write the report, make the final commit, clear for a fresh goal |
230
232
 
231
233
  **Plan-shaping moves**: optional, for when the plan needs work mid-flight:
@@ -233,7 +235,7 @@ nothing else:
233
235
  | Skill <img alt="" width="110" height="1"> | Does |
234
236
  | ------------------------------------ | ------ |
235
237
  | `/plumbbob:step` | revise/sharpen the next step (empty input auto-syncs it to reality) |
236
- | `/plumbbob:refine` | attack the frame for holes, or repair a drifted plan |
238
+ | `/plumbbob:refine` | attack the frame for holes, or repair a drifted plan (`ranges` re-cuts the build ranges) |
237
239
  | `/plumbbob:spike` | throwaway worktree experiment for a fork the plan can't settle |
238
240
 
239
241
  **Helpers**: orient, verify, recover, diagnose:
package/dist/cli-core.js CHANGED
@@ -146,10 +146,15 @@ const VERBS = [
146
146
  {
147
147
  name: 'order',
148
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' }],
149
+ synopsis: ['order <steps>', 'order --reset'],
150
+ args: [
151
+ {
152
+ name: '<steps>',
153
+ gloss: 'undone step numbers and spans (2-4) in the sequence to build them, space- or comma-separated (1, 6, 2-4, 7, 5); the rest follow in document order',
154
+ },
155
+ ],
151
156
  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.',
157
+ notes: 'Writes the one numeric line under `## Build order` in intent.md, which status, build, checkpoint, and handoff all read. A span is spelled out on the line. Refuses (exit 1) with no session, no numbers, a span that runs backward, a number the plan lacks, a repeat, or a step already checkpointed.',
153
158
  },
154
159
  {
155
160
  name: 'spike',
package/dist/lib/latch.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import { readFileSync } from 'node:fs';
18
18
  import { grantPath, tickPath, turnPath } from "./sidecar.js";
19
19
  import { resolveBoolean } from "./settings.js";
20
+ import { hasStep, normalizeStepList, parseStepList } from "./steplist.js";
20
21
  const ALLOW = { allow: true };
21
22
  /**
22
23
  * The refusal message is the affordance; it tells the model what the pause is,
@@ -51,12 +52,20 @@ const SETTINGS_AUTO_NOTE = `note: \`auto\` is set in settings but is no longer a
51
52
  function ceilingMessage(ceiling) {
52
53
  return `plumbbob: checkpoint refused — the range you granted ends at step ${ceiling} — pause here; run it again to continue.\n`;
53
54
  }
55
+ /**
56
+ * The refusal for a step a list grant does not name, naming the steps it does.
57
+ */
58
+ function outsideMessage(step, steps) {
59
+ return `plumbbob: checkpoint refused — step ${step} is not in the range you granted (${normalizeStepList(steps)}) — pause here; run it again to continue.\n`;
60
+ }
54
61
  /**
55
62
  * The five-row matrix, in order, first hit wins.
56
63
  *
57
- * A `range` grant speaks only to numbered steps: on a `--plan` checkpoint (step
64
+ * A range grant speaks only to numbered steps: on a `--plan` checkpoint (step
58
65
  * null) it neither allows nor refuses, and the decision falls through to the
59
- * turn comparison.
66
+ * turn comparison. A `steps` grant lands exactly the steps the human typed and
67
+ * refuses any other, so no later edit to the plan or its build order can widen
68
+ * it; both kinds refuse with the `ceiling` reason, a step outside the range.
60
69
  */
61
70
  export function evaluateLatch(input) {
62
71
  if (input.isTTY)
@@ -65,6 +74,11 @@ export function evaluateLatch(input) {
65
74
  return ALLOW; // 2: dormant ledger / hand-built diff
66
75
  if (input.grant?.kind === 'auto')
67
76
  return ALLOW; // 3: one-turn grant the human typed
77
+ if (input.grant?.kind === 'steps' && input.step !== null) {
78
+ if (hasStep(input.grant.steps, input.step))
79
+ return ALLOW;
80
+ return { allow: false, reason: 'ceiling', message: outsideMessage(input.step, input.grant.steps) };
81
+ }
68
82
  if (input.grant?.kind === 'range' && input.step !== null) {
69
83
  if (input.step <= input.grant.ceiling)
70
84
  return ALLOW;
@@ -78,15 +92,19 @@ export function evaluateLatch(input) {
78
92
  return { allow: false, reason: 'no-turn', message: input.settingsAuto ? base + SETTINGS_AUTO_NOTE : base };
79
93
  }
80
94
  /**
81
- * Parse a GRANT file's content (`auto` | `range <M>`).
95
+ * Parse a GRANT file's content (`auto` | `steps <list>` | `range <M>`).
82
96
  *
83
- * Anything else (including a corrupted or hand-mangled file) contributes
84
- * nothing: no grant, never an error.
97
+ * Anything else (including a corrupted or hand-mangled file, or a list that
98
+ * does not parse) contributes nothing: no grant, never an error.
85
99
  */
86
100
  export function parseGrant(raw) {
87
101
  const trimmed = raw.trim();
88
102
  if (trimmed === 'auto')
89
103
  return { kind: 'auto' };
104
+ const listed = /^steps (\S+)$/.exec(trimmed);
105
+ const steps = listed === null ? null : parseStepList(listed[1] ?? '');
106
+ if (steps !== null)
107
+ return { kind: 'steps', steps };
90
108
  const range = /^range (\d+)$/.exec(trimmed);
91
109
  if (range !== null)
92
110
  return { kind: 'range', ceiling: Number(range[1]) };
@@ -67,13 +67,14 @@ export function advisory(a) {
67
67
  }
68
68
  /**
69
69
  * Assemble a transition's whole ending: the labeled lead line, the Verdict
70
- * where one was measured, the advisories, and the pointer.
70
+ * where one was measured, the advisories, the Stop part where a range landed,
71
+ * and the pointer.
71
72
  *
72
73
  * The order is the type's field order, so a verb composes parts and never a
73
74
  * sequence. A part with nothing in it vanishes rather than leaving a gap.
74
75
  */
75
76
  export function ending(e) {
76
- return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.pointer ?? null]);
77
+ return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.stop ?? null, e.pointer ?? null]);
77
78
  }
78
79
  /**
79
80
  * Stack a turn's parts: one blank line between each, one beneath the last.
@@ -4,6 +4,7 @@
4
4
  // contents (no fs), and a malformed document degrades to fewer fields rather
5
5
  // than throwing.
6
6
  import { parseStepSeam, scopeDrift } from "./intent.js";
7
+ import { expandStepList, formatStepList, hasStep, normalizeStepList, parseStepList, readBuildArgs, } from "./steplist.js";
7
8
  /**
8
9
  * The lines of a named `## Section`, from its heading to the next `## ` (or
9
10
  * EOF), with the offset of the first line so a caller can name an absolute
@@ -74,33 +75,35 @@ export function parseSteps(intent) {
74
75
  return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model, line: offset + s.idx + 1 };
75
76
  });
76
77
  }
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*,?$/;
78
+ // A build-order line: step numbers and spans (`2-4`) joined by commas, with or
79
+ // without a space after each, and nothing else. A trailing comma is tolerated,
80
+ // since a hand edit leaves one behind.
81
+ const BUILD_ORDER_LINE = /^\d+(?:-\d+)?(?:\s*,\s*\d+(?:-\d+)?)*\s*,?$/;
80
82
  /**
81
83
  * 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.
84
+ * first line that is nothing but numbers, spans, and commas, in the sequence
85
+ * written, each span spelled out in place (`1, 6, 2-4` reads 1, 6, 2, 3, 4).
83
86
  *
84
87
  * The line is the plan's own statement of what to build next once the
85
88
  * numbering stops carrying it (a refined plan appends a step 7 that has to
86
89
  * land before step 5), and `plumbbob order` is the verb that writes it. The
87
90
  * 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
+ * which every picker takes to mean document order, and so does a line holding
92
+ * a span that runs backward. Numbers come back verbatim, repeats and unknowns
93
+ * included: `orderSteps` does the cleaning, and `recover` wants the raw read to
94
+ * say which number the plan lacks. A span stops at the first number past the
95
+ * plan, which it keeps for `recover` to name.
91
96
  */
92
97
  export function parseBuildOrder(intent) {
93
98
  const line = sectionLines(intent, '## Build order')
94
99
  .lines.map((l) => l.trim())
95
100
  .find((l) => BUILD_ORDER_LINE.test(l));
96
- if (line === undefined) {
101
+ const list = line === undefined ? null : parseStepList(line.replace(/\s+/g, '').replace(/,$/, ''));
102
+ if (list === null) {
97
103
  return [];
98
104
  }
99
- return line
100
- .split(',')
101
- .map((n) => n.trim())
102
- .filter((n) => n.length > 0)
103
- .map(Number);
105
+ const planned = new Set(parseSteps(intent).map((s) => s.n));
106
+ return expandStepList(list, (n) => planned.has(n));
104
107
  }
105
108
  /**
106
109
  * The steps in build order: the numbers `order` lists first, in that sequence,
@@ -234,6 +237,246 @@ export function clearBuildOrder(intent) {
234
237
  }
235
238
  return lines.join('\n');
236
239
  }
240
+ // A range's opener: the item's number, the command in backticks, a comma, and
241
+ // the stop's text from `**stop:**` to the end of the line.
242
+ const RANGE_OPENER = /^`([^`]*)`\s*,?\s*\*\*stop:\*\*\s*(.*)$/i;
243
+ // A `<...>` fill-in the template ships, still waiting for its words.
244
+ const FILL_IN = /<[^<>]+>/;
245
+ /**
246
+ * Parse the ranges under `## Build ranges`: one numbered item per range, the
247
+ * command in backticks, then `**stop:**` and the stop's text, then an optional
248
+ * `- model:` sub-line.
249
+ *
250
+ * The command's steps go through the turn hook's own reader, so the card can
251
+ * never suggest a command the hook reads another way. A paragraph before the
252
+ * list and any sub-line but the model are the reader's. An item still holding
253
+ * a `<...>` fill-in in its command or stop is scaffold, the template's
254
+ * placeholder waiting for words, and reads as neither a range nor a report; any
255
+ * other item that fails the grammar is reported by its number and never
256
+ * thrown. A plan without the section, a build's record from before ranges
257
+ * existed under another heading, and a section holding only the placeholder
258
+ * all read as no ranges.
259
+ */
260
+ export function parseBuildRanges(intent) {
261
+ const { offset, lines } = sectionLines(intent, '## Build ranges');
262
+ const ranges = [];
263
+ const malformed = [];
264
+ const openers = lines.flatMap((l, idx) => {
265
+ const m = /^(\d+)\.\s+(.*)$/.exec(l);
266
+ return m === null ? [] : [{ item: Number(m[1]), text: m[2] ?? '', idx }];
267
+ });
268
+ openers.forEach((o, i) => {
269
+ const block = lines.slice(o.idx + 1, openers[i + 1]?.idx ?? lines.length);
270
+ const text = [o.text, ...wrappedLines(block)].join(' ').trim();
271
+ if (FILL_IN.test(text)) {
272
+ return;
273
+ }
274
+ const range = readRange(o.item, text, block, offset + o.idx + 1);
275
+ if (range === null) {
276
+ malformed.push(o.item);
277
+ }
278
+ else {
279
+ ranges.push(range);
280
+ }
281
+ });
282
+ return { ranges, malformed };
283
+ }
284
+ /**
285
+ * The lines an item's opener wraps onto: each line after it, trimmed, up to the
286
+ * first blank line, sub-line, or heading.
287
+ */
288
+ function wrappedLines(block) {
289
+ const end = block.findIndex((l) => l.trim() === '' || /^\s*[-*+]\s/.test(l) || l.startsWith('#'));
290
+ return block.slice(0, end === -1 ? block.length : end).map((l) => l.trim());
291
+ }
292
+ /**
293
+ * One item read as a range, or null when it is not one: a command other than
294
+ * `/plumbbob:build` with steps and nothing after them, a bare command naming
295
+ * more than one step, or a stop with no text.
296
+ */
297
+ function readRange(item, text, block, line) {
298
+ const m = RANGE_OPENER.exec(text);
299
+ const args = /^\/plumbbob:build\s+([\d,\s-]+)$/.exec((m?.[1] ?? '').trim())?.[1];
300
+ const stop = (m?.[2] ?? '').trim();
301
+ if (args === undefined || stop === '') {
302
+ return null;
303
+ }
304
+ const read = readBuildArgs(args);
305
+ const single = read.steps?.length === 1 && read.steps[0]?.from === read.steps[0]?.to;
306
+ if (read.steps === null || (!read.range && !single)) {
307
+ return null;
308
+ }
309
+ const model = /^\s*-\s*model:\s*(.+)$/im.exec(block.join('\n'))?.[1]?.trim() ?? null;
310
+ return {
311
+ item,
312
+ command: (m?.[1] ?? '').trim(),
313
+ steps: read.steps,
314
+ bare: !read.range,
315
+ stop,
316
+ model: model === null || FILL_IN.test(model) ? null : model,
317
+ line,
318
+ };
319
+ }
320
+ /**
321
+ * Where the build stands in its ranges, read from `step`: the range holding
322
+ * it, the command that runs what is left of that range, the range its landing
323
+ * completed, and every range the plan has outgrown.
324
+ *
325
+ * Nothing is stored. A range is landed once every step it names is done, and
326
+ * what is left of it is its undone steps from `step` on, in build order, so a
327
+ * step pause names what is left once the step in flight lands. A landing
328
+ * range down to one step writes it `9-9`, which lands it, where a bare range
329
+ * stays bare. `ordered` is the plan's steps in build order, landed ones
330
+ * included. With no range that parses, no step sits outside one, so a fresh
331
+ * build's placeholder flags nothing.
332
+ */
333
+ export function rangeStanding(plan, ordered, step) {
334
+ const drift = rangeDrift(plan, ordered);
335
+ const count = plan.ranges.length + plan.malformed.length;
336
+ const at = ordered.findIndex((s) => s.n === step);
337
+ const holding = plan.ranges.find((r) => step !== null && hasStep(r.steps, step));
338
+ if (holding === undefined) {
339
+ const outside = step !== null && plan.ranges.length > 0 && ordered[at]?.done === false;
340
+ return { count, holding: null, command: null, flag: outside ? noRangeWords([step]) : null, completed: null, drift };
341
+ }
342
+ const flag = drift.find((d) => d.item === holding.item && d.kind !== 'malformed')?.words ?? null;
343
+ const left = at === -1 ? [] : ordered.slice(at).filter((s) => !s.done && hasStep(holding.steps, s.n));
344
+ return {
345
+ count,
346
+ holding,
347
+ command: flag === null && left.length > 0 ? `/plumbbob:build ${leftExpression(holding, left)}` : null,
348
+ flag,
349
+ completed: ordered[at]?.done === true && rangeLanded(holding, ordered) ? holding : null,
350
+ drift,
351
+ };
352
+ }
353
+ /** Whether a range has landed: the plan holds a step it names, and every one it holds is done. */
354
+ function rangeLanded(range, ordered) {
355
+ const held = ordered.filter((s) => hasStep(range.steps, s.n));
356
+ return held.length > 0 && held.every((s) => s.done);
357
+ }
358
+ /**
359
+ * The ranges block the dashboard renders, and the move it suggests: the range
360
+ * holding the next undone step and the command that runs what is left of it.
361
+ *
362
+ * Null for a plan without ranges, so the block vanishes. The move is null when
363
+ * the next step sits in no range, its range is flagged, or the step is still
364
+ * unplanned, which leaves the dashboard's own step move in charge.
365
+ */
366
+ function rangesView(intent, ordered, next) {
367
+ const plan = parseBuildRanges(intent);
368
+ const standing = rangeStanding(plan, ordered, next?.n ?? null);
369
+ if (standing.count === 0) {
370
+ return { view: null, move: null };
371
+ }
372
+ const own = (item) => standing.drift.filter((d) => d.item === item && d.kind !== 'malformed');
373
+ const rows = plan.ranges.map((range) => ({
374
+ range,
375
+ landed: rangeLanded(range, ordered),
376
+ next: range === standing.holding,
377
+ drift: own(range.item).map((d) => d.words),
378
+ }));
379
+ const view = { count: standing.count, rows, plan: standing.drift.filter((d) => d.item === null || d.kind === 'malformed').map((d) => d.words) };
380
+ const { holding, command } = standing;
381
+ const move = holding === null || command === null || next?.planned !== true ? null : `build range ${holding.item} of ${standing.count} — \`${command}\``;
382
+ return { view, move };
383
+ }
384
+ /**
385
+ * What is left of a range as the steps of a command: a landing range's last
386
+ * step written `9-9` so the command lands it, else the steps in build order
387
+ * with each climbing run folded to a span, a bare range's lone number included.
388
+ */
389
+ function leftExpression(range, left) {
390
+ const ns = left.map((s) => s.n);
391
+ return !range.bare && ns.length === 1 ? `${ns[0]}-${ns[0]}` : formatStepList(ns);
392
+ }
393
+ /** The words for steps no range names: `no range names step 12`, or `steps 11, 12`. */
394
+ function noRangeWords(steps) {
395
+ return `no range names ${stepPhrase(steps.map(String)).words}`;
396
+ }
397
+ /**
398
+ * Every report on the ranges, in the order the dashboard lists them: each
399
+ * range's own (a step the plan lacks, a step another range also names, a step
400
+ * from outside it the build order puts among its own), then the undone steps
401
+ * no range names, then the items that do not parse.
402
+ *
403
+ * A step the build order puts inside a range, and a step no range names,
404
+ * count only while undone, since a landed step cannot cut a run short. The
405
+ * coverage check runs only once a real range parses.
406
+ */
407
+ function rangeDrift(plan, ordered) {
408
+ const own = plan.ranges.flatMap((r) => [
409
+ ...missingDrift(r, ordered),
410
+ ...sharedDrift(r, plan.ranges, ordered),
411
+ ...insideDrift(r, ordered),
412
+ ]);
413
+ const outside = ordered.filter((s) => !s.done && !plan.ranges.some((r) => hasStep(r.steps, s.n))).map((s) => s.n);
414
+ const uncovered = plan.ranges.length > 0 && outside.length > 0 ? [{ kind: 'uncovered', item: null, words: noRangeWords(outside) }] : [];
415
+ const malformed = plan.malformed.map((item) => ({ kind: 'malformed', item, words: `item ${item} under Build ranges does not parse` }));
416
+ return [...own, ...uncovered, ...malformed];
417
+ }
418
+ /**
419
+ * A range naming steps the plan lacks, reported as the spans it names past
420
+ * the plan's steps, so a span of any width is read without being counted out.
421
+ */
422
+ function missingDrift(range, ordered) {
423
+ const held = ordered.map((s) => s.n).sort((a, b) => a - b);
424
+ const gaps = [];
425
+ for (const span of range.steps) {
426
+ let from = span.from;
427
+ for (const n of held.filter((h) => h >= span.from && h <= span.to)) {
428
+ if (n > from) {
429
+ gaps.push({ from, to: n - 1 });
430
+ }
431
+ from = Math.max(from, n + 1);
432
+ }
433
+ if (from <= span.to) {
434
+ gaps.push({ from, to: span.to });
435
+ }
436
+ }
437
+ if (gaps.length === 0) {
438
+ return [];
439
+ }
440
+ const { words, be } = stepPhrase(normalizeStepList(gaps).split(','));
441
+ return [{ kind: 'missing', item: range.item, words: `${words} ${be} not in the plan` }];
442
+ }
443
+ /** A range sharing steps with another range, one report for each other range. */
444
+ function sharedDrift(range, ranges, ordered) {
445
+ const mine = ordered.filter((s) => hasStep(range.steps, s.n));
446
+ return ranges.flatMap((other) => {
447
+ const both = other === range ? [] : mine.filter((s) => hasStep(other.steps, s.n)).map((s) => String(s.n));
448
+ if (both.length === 0) {
449
+ return [];
450
+ }
451
+ const { words, be } = stepPhrase(both);
452
+ return [{ kind: 'shared', item: range.item, words: `${words} ${be} also in range ${other.item}` }];
453
+ });
454
+ }
455
+ /**
456
+ * A range the build order cuts short: an undone step it does not name, placed
457
+ * between two of its own, where a run of the range halts.
458
+ *
459
+ * An outside step ahead of the range's first step builds first and cuts
460
+ * nothing, and the card names its range before this one.
461
+ */
462
+ function insideDrift(range, ordered) {
463
+ const first = ordered.findIndex((s) => hasStep(range.steps, s.n));
464
+ const last = ordered.findLastIndex((s) => hasStep(range.steps, s.n));
465
+ const inside = first === -1 ? [] : ordered.slice(first + 1, last).filter((s) => !s.done && !hasStep(range.steps, s.n));
466
+ if (inside.length === 0) {
467
+ return [];
468
+ }
469
+ const words = stepPhrase(inside.map((s) => String(s.n))).words;
470
+ return [{ kind: 'inside', item: range.item, words: `the build order puts ${words} inside it` }];
471
+ }
472
+ /**
473
+ * Steps as a phrase, with the verb that agrees: `step 12` and `is`, or
474
+ * `steps 11, 12` and `are`. Each item is a number or a span as it is typed.
475
+ */
476
+ function stepPhrase(items) {
477
+ const one = items.length === 1 && /^\d+$/.test(items[0] ?? '');
478
+ return { words: `${one ? 'step' : 'steps'} ${items.join(', ')}`, be: one ? 'is' : 'are' };
479
+ }
237
480
  /**
238
481
  * Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section.
239
482
  *
@@ -365,27 +608,29 @@ export function lastLedgerSha(checkpoints) {
365
608
  return last;
366
609
  }
367
610
  /**
368
- * The step number an explicit skill invocation asks for, out of the raw
369
- * argument text `status --invoked` receives.
611
+ * The steps an explicit skill invocation asks for, out of the raw argument
612
+ * text `status --invoked` receives, read the way the turn hook reads a typed
613
+ * range.
370
614
  *
371
- * The first whitespace-separated token shaped like `22` or `22-24` names the
372
- * step (a range asks to start at its first number); flags such as `--auto` and
373
- * anything else parse to null, meaning "no explicit ask", which leaves the
374
- * dashboard's own suggestion in charge. Null in means null out, so a host that
375
- * never substitutes the invocation degrades to today's rendering.
615
+ * A step number names one step, and a range or a list names the steps it
616
+ * holds (`orient` starts it at the first undone one in build order); `--auto`
617
+ * alone, a range that does not parse, and free text parse to null, meaning "no
618
+ * explicit ask", which leaves the dashboard's own suggestion in charge. Null in
619
+ * means null out, so a host that never substitutes the invocation degrades to
620
+ * today's rendering.
376
621
  */
377
622
  export function parseRequestedStep(raw) {
378
- if (raw === null) {
379
- return null;
380
- }
381
- for (const token of raw.trim().split(/\s+/)) {
382
- const m = /^(\d+)(?:-\d+)?$/.exec(token);
383
- if (m) {
384
- const n = Number(m[1]);
385
- return n >= 1 ? n : null;
386
- }
387
- }
388
- return null;
623
+ return raw === null ? null : readBuildArgs(raw.trim()).steps;
624
+ }
625
+ /**
626
+ * The step a requested list starts at: its first undone step in build order,
627
+ * however the list was written; its first step in build order when every one
628
+ * has landed; or its first number written when the plan holds none of them, so
629
+ * the dashboard reports that number missing. Null for an empty list.
630
+ */
631
+ export function rangeStart(ordered, list) {
632
+ const named = ordered.filter((s) => hasStep(list, s.n));
633
+ return (named.find((s) => !s.done) ?? named[0])?.n ?? list[0]?.from ?? null;
389
634
  }
390
635
  /**
391
636
  * The single primary next move the dashboard suggests.
@@ -454,15 +699,22 @@ export function orient(input) {
454
699
  const order = parseBuildOrder(input.intent);
455
700
  const ordered = orderSteps(steps, order);
456
701
  const parks = parseParkTotals(input.buildLog);
702
+ // The step an explicit ask points at: the number typed, or where a typed
703
+ // range starts in build order.
704
+ const requested = input.requested === null ? null : rangeStart(ordered, input.requested);
457
705
  // The target the dashboard details: the explicitly requested step when it
458
706
  // exists in the plan, else the next undone. A requested number that names no
459
707
  // planned step carries nothing here; the next-move line reports the mismatch
460
708
  // while the rest of the dashboard renders as usual.
461
- const requestedStep = input.requested === null ? undefined : steps.find((s) => s.n === input.requested);
709
+ const requestedStep = requested === null ? undefined : steps.find((s) => s.n === requested);
462
710
  const nextUndone = ordered.find((s) => !s.done);
463
711
  const target = requestedStep ?? nextUndone;
464
712
  const seamParse = target === undefined ? null : parseStepSeam(input.intent, target.n);
465
713
  const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
714
+ // At the boundary with nothing asked for, the move names the range holding
715
+ // the next step; a spike, a step in flight, or a request keeps its own.
716
+ const ranges = rangesView(input.intent, ordered, nextUndone);
717
+ const atBoundary = !input.spiking && input.inFlight === null && requested === null;
466
718
  return {
467
719
  title: parseTitle(input.intent),
468
720
  phase,
@@ -471,9 +723,10 @@ export function orient(input) {
471
723
  parked: parks.open,
472
724
  parkedTotal: parks.total,
473
725
  openQuestions: parseOpenQuestions(input.intent),
474
- next: nextMove(input.spiking, ordered, input.inFlight, parks.open, input.requested),
726
+ next: (atBoundary ? ranges.move : null) ?? nextMove(input.spiking, ordered, input.inFlight, parks.open, requested),
475
727
  nextUndone: nextUndone?.n ?? null,
476
728
  buildOrder: remainingOrder(steps, order, null),
729
+ ranges: ranges.view,
477
730
  requested: requestedStep?.n ?? null,
478
731
  nextDoneWhen: target?.doneWhen ?? null,
479
732
  nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
@@ -1024,6 +1277,7 @@ export function formatOrientation(o) {
1024
1277
  return detail.length > 0 ? [head, ...detail].join('\n') : head;
1025
1278
  });
1026
1279
  const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
1280
+ const rangesBlock = o.ranges === null ? [] : [formatRanges(o.ranges), ''];
1027
1281
  const cp = o.lastCheckpoint;
1028
1282
  const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
1029
1283
  // A neutral reconciliation note, only when there is something to reconcile:
@@ -1043,6 +1297,7 @@ export function formatOrientation(o) {
1043
1297
  '',
1044
1298
  stepsBlock,
1045
1299
  '',
1300
+ ...rangesBlock,
1046
1301
  cpLine,
1047
1302
  ...receipts,
1048
1303
  ...orderRow,
@@ -1051,3 +1306,22 @@ export function formatOrientation(o) {
1051
1306
  `next → ${o.next}`,
1052
1307
  ].join('\n');
1053
1308
  }
1309
+ /**
1310
+ * The dashboard's ranges block, in the step list's own grammar: a count, then a
1311
+ * row per range with its command as the plan wrote it and its model, `✓` for
1312
+ * a range landed and `▸` with the `← next` tail for the one holding the next
1313
+ * step. An undone range carries its stop beneath it, since the stops ahead are
1314
+ * what the human steers by, and a landed one folds to its row. A range's drift
1315
+ * rides beneath it, and the plan's own rows close the block.
1316
+ */
1317
+ function formatRanges(view) {
1318
+ const landed = view.rows.filter((r) => r.landed).length;
1319
+ const rows = view.rows.flatMap((r) => {
1320
+ const marker = r.landed ? '✓' : r.next ? '▸' : ' ';
1321
+ const model = r.range.model === null ? '' : ` · ${r.range.model}`;
1322
+ const head = ` ${marker} ${r.range.item} ${r.range.command}${model}${r.next ? ' ← next' : ''}`;
1323
+ const stop = r.landed ? [] : [` stop: ${r.range.stop}`];
1324
+ return [head, ...stop, ...r.drift.map((words) => ` ⚠ needs a re-cut: ${words}`)];
1325
+ });
1326
+ return [` ranges ${landed} of ${view.count} done`, ...rows, ...view.plan.map((words) => ` ⚠ ${words}`)].join('\n');
1327
+ }
@@ -473,6 +473,9 @@ export function clearSpike(root, slug) {
473
473
  // `plumbbob turn` (the UserPromptSubmit hook) ticks TURN once per human prompt
474
474
  // and rewrites GRANT from the literal prompt; the checkpoint latch reads them to
475
475
  // refuse a land until a human turn has intervened since the step was entered.
476
+ // A background job's notice reaches the same hook and ticks neither; PROMPT,
477
+ // beside them, records the id of the last prompt the hook saw, so the hook can
478
+ // tell a notice folded into the running turn from one that starts its own.
476
479
  // Kept in the sidecar so the lone GRANT `rmSync` stays where deletions of control
477
480
  // state are allowed. ---
478
481
  /**
@@ -504,6 +507,41 @@ export function setGrant(root, grant) {
504
507
  writeFileSync(grantPath(root), `${grant}\n`);
505
508
  }
506
509
  }
510
+ /**
511
+ * PROMPT: the id of the last prompt the turn hook saw, typed or not.
512
+ */
513
+ export function promptPath(root) {
514
+ return join(root, DIRNAME, 'PROMPT');
515
+ }
516
+ /**
517
+ * The id of the last prompt the turn hook saw, or null when none is recorded.
518
+ *
519
+ * A notice the harness folds into a running turn arrives under that turn's id,
520
+ * where one that starts a turn of its own arrives under a fresh one, so the
521
+ * hook compares a notice's id against this one to tell the two apart.
522
+ */
523
+ export function readPrompt(root) {
524
+ try {
525
+ const id = readFileSync(promptPath(root), 'utf8').trim();
526
+ return id === '' ? null : id;
527
+ }
528
+ catch {
529
+ return null;
530
+ }
531
+ }
532
+ /**
533
+ * Record the id of the prompt the hook just saw, or clear the record when the
534
+ * payload carried none, so a stale id can never make a later notice look as
535
+ * though it joined the running turn.
536
+ */
537
+ export function notePrompt(root, id) {
538
+ if (id === null) {
539
+ rmSync(promptPath(root), { force: true });
540
+ }
541
+ else {
542
+ writeFileSync(promptPath(root), `${id}\n`);
543
+ }
544
+ }
507
545
  /**
508
546
  * TICK: the per-build entry stamp: the TURN value recorded when work was
509
547
  * entered (`build <n>` for a step, `start` for the plan), cleared when
@@ -612,6 +650,18 @@ export function writeRelay(root, relay) {
612
650
  // A read-only tree or a directory in the way: the ending already printed.
613
651
  }
614
652
  }
653
+ /**
654
+ * Drop the recorded ending, so the `Stop` check has nothing to judge until a
655
+ * verb prints another.
656
+ *
657
+ * The turn hook calls this when a background job's notice starts a turn of its
658
+ * own. The ending was judged when its own turn ended, and a notice leaves TURN
659
+ * where it was, so without this the notice's turn would read as the one that
660
+ * printed the ending and be held open for a card the human has already read.
661
+ */
662
+ export function clearRelay(root) {
663
+ rmSync(relayPath(root), { force: true });
664
+ }
615
665
  /**
616
666
  * Stamp the record with the turn the check held open over it, so the check
617
667
  * holds at most once per turn. False when the stamp could not be written, and
@@ -739,6 +789,9 @@ export function excludeControl(root) {
739
789
  // never writes, and never commits.
740
790
  `${DIRNAME}/TURN`,
741
791
  `${DIRNAME}/GRANT`,
792
+ // The id of the last prompt the turn hook saw, which tells a notice folded
793
+ // into the running turn from one that starts its own.
794
+ `${DIRNAME}/PROMPT`,
742
795
  // The in-flight step's detail file: flat per-session control the model
743
796
  // overwrites before every pause. checkpoint records it in the build-log's
744
797
  // Log and then clears it, so its archive is the tracked ledger, never this