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.
@@ -0,0 +1,128 @@
1
+ // A step expression: the one grammar a `/plumbbob:build` range is typed in. The
2
+ // turn hook reads it to mint a grant, the latch to land exactly what the grant
3
+ // names, `build` to say where a range stops, and the dashboard to point at where
4
+ // one starts, so no two of them can read the same keystrokes two ways. An
5
+ // expression is one number, a span `N-M` with N no greater than M, or numbers
6
+ // and spans joined by commas (`20,5-9`). It names plan numbers, never positions
7
+ // in the build order, and it is held as the spans it was written in, so
8
+ // `1-100000` stays two numbers rather than a hundred thousand. Pure: no fs.
9
+ /**
10
+ * Parse a step expression, or null when it is anything else.
11
+ *
12
+ * Every item is a positive number or a span whose first number is no greater
13
+ * than its last. An empty item, a stray dash, or a space inside the expression
14
+ * reads as no list at all, so a malformed range names nothing and grants
15
+ * nothing.
16
+ */
17
+ export function parseStepList(text) {
18
+ const spans = [];
19
+ for (const item of text.split(',')) {
20
+ const m = /^(\d+)(?:-(\d+))?$/.exec(item);
21
+ if (m === null)
22
+ return null;
23
+ const from = Number(m[1]);
24
+ const to = m[2] === undefined ? from : Number(m[2]);
25
+ if (!Number.isSafeInteger(to) || from < 1 || to < from)
26
+ return null;
27
+ spans.push({ from, to });
28
+ }
29
+ return spans;
30
+ }
31
+ /** Whether a list names step `n`. */
32
+ export function hasStep(list, n) {
33
+ return list.some((span) => n >= span.from && n <= span.to);
34
+ }
35
+ /**
36
+ * Write steps back as an expression in the order given, folding each run that
37
+ * climbs by one into a span: `[20, 5, 6, 7, 8, 9]` writes `20,5-9`.
38
+ *
39
+ * The order is the caller's, because a list has none of its own: the card
40
+ * writes one in build order, so its first number is where the range starts.
41
+ */
42
+ export function formatStepList(steps) {
43
+ const runs = [];
44
+ for (const n of steps) {
45
+ const last = runs.at(-1);
46
+ if (last !== undefined && n === last.to + 1) {
47
+ last.to = n;
48
+ }
49
+ else {
50
+ runs.push({ from: n, to: n });
51
+ }
52
+ }
53
+ return runs.map(spanText).join(',');
54
+ }
55
+ /**
56
+ * A list's normal form: its spans sorted and merged, so two ways of typing the
57
+ * same steps write the same line (`20, 5-9` and `5-9,20` both write `5-9,20`).
58
+ *
59
+ * The grant stores this form and never the expansion, so a range of any length
60
+ * is one short line.
61
+ */
62
+ export function normalizeStepList(list) {
63
+ const merged = [];
64
+ for (const span of [...list].sort((a, b) => a.from - b.from)) {
65
+ const last = merged.at(-1);
66
+ if (last !== undefined && span.from <= last.to + 1) {
67
+ last.to = Math.max(last.to, span.to);
68
+ }
69
+ else {
70
+ merged.push({ from: span.from, to: span.to });
71
+ }
72
+ }
73
+ return merged.map(spanText).join(',');
74
+ }
75
+ /**
76
+ * The numbers a list names, in the order written, each span walked only while
77
+ * `known` holds and stopped at the first number it does not, which is kept.
78
+ *
79
+ * A build order is a sequence rather than a set, so its spans expand in place
80
+ * (`1,6,2-4` names 1, 6, 2, 3, 4). The number kept past the plan is the one a
81
+ * caller reports the plan lacks, and stopping there means `2-100000` costs one
82
+ * number past the last planned step rather than a hundred thousand.
83
+ */
84
+ export function expandStepList(list, known) {
85
+ const numbers = [];
86
+ for (const span of list) {
87
+ for (let n = span.from; n <= span.to; n += 1) {
88
+ numbers.push(n);
89
+ if (!known(n))
90
+ break;
91
+ }
92
+ }
93
+ return numbers;
94
+ }
95
+ /** One span as it is typed: a lone number, or `from-to`. */
96
+ function spanText(span) {
97
+ return span.from === span.to ? String(span.from) : `${span.from}-${span.to}`;
98
+ }
99
+ /**
100
+ * Read the arguments a build invocation carries, up to the first token that is
101
+ * not one: `--auto`, or a run of digits, commas, and dashes.
102
+ *
103
+ * Once any number carries a comma or a dash, every number and span in the run
104
+ * joins one list, so `20, 5-9` typed with a space reads as `20,5-9`. Trailing
105
+ * sentence punctuation is the sentence's (`/build 1-3.`), and so is a comma
106
+ * after the run's last number (`/build 2, then review`), which separates
107
+ * nothing.
108
+ */
109
+ export function readBuildArgs(text) {
110
+ let auto = false;
111
+ const words = [];
112
+ for (const token of text.split(/\s+/)) {
113
+ const word = token.replace(/[.;:!?]+$/, '');
114
+ if (word === '')
115
+ continue;
116
+ if (word === '--auto') {
117
+ auto = true;
118
+ continue;
119
+ }
120
+ if (!/^[\d,-]+$/.test(word))
121
+ break;
122
+ words.push(word);
123
+ }
124
+ const items = words.map((w, i) => (i === words.length - 1 ? w.replace(/,+$/, '') : w)).filter((w) => w !== '');
125
+ const range = items.some((w) => /[,-]/.test(w));
126
+ const steps = items.length === 0 ? null : parseStepList(items.join(',').replace(/,{2,}/g, ','));
127
+ return { auto, range, steps };
128
+ }
@@ -8,18 +8,21 @@ import { readFileSync, writeFileSync } from 'node:fs';
8
8
  import { findRepoRoot } from "../lib/git.js";
9
9
  import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTick, stepPath } from "../lib/sidecar.js";
10
10
  import { parseStepSeam } from "../lib/intent.js";
11
- import { parseOrderedSteps, skippedBefore } from "../lib/orient.js";
11
+ import { parseBuildRanges, parseOrderedSteps, rangeStanding, rangeStart, skippedBefore } from "../lib/orient.js";
12
12
  import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
13
13
  import { readGrant } from "../lib/latch.js";
14
14
  import { notice } from "../lib/notice.js";
15
+ import { hasStep, readBuildArgs } from "../lib/steplist.js";
15
16
  /**
16
17
  * Enter step n (or the next undone step) and write its SEAM/STEP markers.
17
18
  *
18
- * Refuses a missing session, a malformed step argument, an `N-M` range (a
19
- * `/plumbbob:build` skill feature, not a CLI one), and a seam that fails to parse.
19
+ * Refuses a missing session, a malformed step argument, a range such as `N-M`
20
+ * or `20,5-9` (a `/plumbbob:build` skill feature, not a CLI one), and a seam
21
+ * that fails to parse.
20
22
  * On entry it stamps the turn ledger and the step's start time, re-renders the
21
23
  * build-log's Current step line, and names the step when it is the last one a
22
- * self-approval grant reaches.
24
+ * self-approval grant reaches, or, under `--auto`, a call the plan's ranges
25
+ * reserve for the human.
23
26
  */
24
27
  export function build(cwd, args) {
25
28
  const root = findRepoRoot(cwd);
@@ -29,15 +32,15 @@ export function build(cwd, args) {
29
32
  }
30
33
  const { build: slug, rest } = resolveBuild(root, args);
31
34
  const raw = rest.find((a) => !a.startsWith('--'));
32
- // A step range like `1-3` is a `/plumbbob:build` skill affordance (auto-approve
33
- // every step through the range's top, then stop), not a CLI capability: the CLI
35
+ // A step range like `1-3` or `20,5-9` is a `/plumbbob:build` skill affordance
36
+ // (auto-approve each step it names, then stop), not a CLI capability: the CLI
34
37
  // records one in-flight step at a time. Name it rather than bounce off the
35
- // generic usage.
36
- if (raw !== undefined && /^\d+-\d*$/.test(raw)) {
38
+ // generic usage, and point at the step the range starts at.
39
+ if (raw !== undefined && /^\d[\d,-]*$/.test(raw) && /[,-]/.test(raw)) {
37
40
  process.stderr.write(notice({
38
41
  fact: 'build takes one step number',
39
42
  detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through its top and then stops`],
40
- remedy: `plumbbob build ${raw.split('-')[0]}`,
43
+ remedy: `plumbbob build ${rangeEntry(intentPath(root, slug), rest.join(' '), raw)}`,
41
44
  }));
42
45
  return 1;
43
46
  }
@@ -100,11 +103,15 @@ export function build(cwd, args) {
100
103
  : skipped > 0
101
104
  ? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
102
105
  : [];
103
- // Two lines (three on the last step a grant reaches), one colon each, then
104
- // the seam as a plain readout beneath the notice that frames it: the paths
105
- // are a list, and a list is not a one-liner.
106
+ // Two lines (three on the last step a grant reaches, or on a call the plan
107
+ // reserves), one colon each, then the seam as a plain readout beneath the
108
+ // notice that frames it: the paths are a list, and a list is not a one-liner.
109
+ // A reserved call outranks the last-step line, since the step stops at its
110
+ // pause rather than landing.
111
+ const grant = readGrant(root);
112
+ const reserved = grant?.kind === 'auto' ? reservedCallNotice(intent, steps, step) : '';
106
113
  process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
107
- lastGrantedNotice(readGrant(root), steps, step) +
114
+ (reserved.length > 0 ? reserved : lastGrantedNotice(grant, steps, step)) +
108
115
  notice({
109
116
  fact: 'the seam is orientation, not a lock',
110
117
  detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
@@ -112,6 +119,45 @@ export function build(cwd, args) {
112
119
  `${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
113
120
  return 0;
114
121
  }
122
+ /**
123
+ * The step a range typed at the CLI starts at: its first undone step in build
124
+ * order when the plan reads, else its first number written.
125
+ *
126
+ * Best-effort, since it only fills the refusal's remedy: a plan that cannot be
127
+ * read, or a range that does not parse, still points at a number the human typed.
128
+ */
129
+ function rangeEntry(intent, args, raw) {
130
+ const list = readBuildArgs(args).steps;
131
+ try {
132
+ const start = list === null ? null : rangeStart(parseOrderedSteps(readFileSync(intent, 'utf8')), list);
133
+ if (start !== null)
134
+ return String(start);
135
+ }
136
+ catch {
137
+ // No plan to order by: fall back to the number typed first.
138
+ }
139
+ return /\d+/.exec(raw)?.[0] ?? '1';
140
+ }
141
+ /**
142
+ * The line saying a step ends at its pause under `--auto`, because the plan's
143
+ * build ranges name it as a bare range: a call the plan reserves for the
144
+ * human. '' for any other step.
145
+ *
146
+ * The halt is this line's and the skill's, never the latch's: the ranges are
147
+ * advice, so `--auto` still grants the checkpoint, and saying so as the step
148
+ * is entered is what puts the stop in front of the model before it lands one.
149
+ */
150
+ function reservedCallNotice(intent, steps, step) {
151
+ const { count, holding } = rangeStanding(parseBuildRanges(intent), steps, step);
152
+ if (holding === null || !holding.bare) {
153
+ return '';
154
+ }
155
+ return notice({
156
+ fact: `step ${step} ends at its pause, where the plan reserves the call for the human`,
157
+ detail: [`range ${holding.item} of ${count}`],
158
+ remedy: 'hold it there for their approval, --auto or not',
159
+ });
160
+ }
115
161
  /**
116
162
  * The line naming the last step a self-approval grant reaches this turn, or ''
117
163
  * for any other step and when no grant is live.
@@ -123,13 +169,26 @@ export function build(cwd, args) {
123
169
  * reaches its top, or the last undone step under its top when the plan ends
124
170
  * first, or when the build order puts a number past the top ahead of the rest
125
171
  * (a range names plan numbers, and the skill halts before one past its top);
126
- * `--auto` reaches the plan's last undone step.
172
+ * `--auto` reaches the plan's last undone step. A range that lists its steps
173
+ * walks them in build order, so its top is the last undone step it names, and
174
+ * it reaches no further than a step it does not name.
127
175
  */
128
176
  function lastGrantedNotice(grant, steps, step) {
129
177
  if (grant === null) {
130
178
  return '';
131
179
  }
132
180
  const remedy = 'land it on green, then stop at the boundary';
181
+ if (grant.kind === 'steps') {
182
+ if (!hasStep(grant.steps, step))
183
+ return '';
184
+ const rest = steps.slice(steps.findIndex((s) => s.n === step) + 1).filter((s) => !s.done && s.n !== step);
185
+ const after = rest[0];
186
+ if (after !== undefined && hasStep(grant.steps, after.n))
187
+ return '';
188
+ return rest.some((s) => hasStep(grant.steps, s.n))
189
+ ? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
190
+ : notice({ fact: `step ${step} is the top of the range you granted`, remedy });
191
+ }
133
192
  if (grant.kind === 'range') {
134
193
  if (step === grant.ceiling) {
135
194
  return notice({ fact: `step ${step} is the top of the range you granted`, remedy });
@@ -129,6 +129,7 @@ export async function checkpoint(cwd, args) {
129
129
  lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
130
130
  verdict: landed.verdict,
131
131
  advisories: [drift, flipNotice],
132
+ stop: landed.stop,
132
133
  pointer: landed.pointer,
133
134
  }),
134
135
  });
@@ -41,10 +41,10 @@ import { createHash } from 'node:crypto';
41
41
  import { readFileSync, writeFileSync } from 'node:fs';
42
42
  import { join, relative } from 'node:path';
43
43
  import { PLAN_COMMIT_MARKER, commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
44
- import { blocks, printEnding } from "../lib/notice.js";
44
+ import { blocks, printEnding, transition } from "../lib/notice.js";
45
45
  import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
46
46
  import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, pausePath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
47
- import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
47
+ import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeStanding, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
48
48
  // The inline-diff threshold (docs/presentation.md): at 20 changed lines or
49
49
  // fewer the patch rides the turn; at 21 it stays in the working tree behind
50
50
  // the counted diff row.
@@ -108,7 +108,7 @@ export function handoff(cwd, args = []) {
108
108
  const detail = settleDetail(root, rawDetail);
109
109
  const plan = [
110
110
  SEAM_RULE,
111
- nextUpBlock(first, steps.length, where, nextUpNote(intent, buildLog, null)),
111
+ nextUpBlock(intent, buildLog, first, null, where),
112
112
  planCallBlock(first),
113
113
  ];
114
114
  const command = reprintCommand('--plan', undefined, named);
@@ -137,7 +137,7 @@ export function handoff(cwd, args = []) {
137
137
  if (current === null) {
138
138
  // Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
139
139
  return emit(root, { tier: 'orientation', render: 'pointer' }, [
140
- nextUpBlock(nextUp, steps.length, where, nextUpNote(intent, buildLog, current)),
140
+ nextUpBlock(intent, buildLog, nextUp, current, where),
141
141
  ]);
142
142
  }
143
143
  // A step is pending, and its hand-off is the pause, while it is in flight.
@@ -147,11 +147,12 @@ export function handoff(cwd, args = []) {
147
147
  // which pause to render.
148
148
  const pending = inFlight !== null || (explicit !== undefined && steps.some((s) => s.n === current && !s.done));
149
149
  if (!pending) {
150
- // The boundary: the orientation-tier ending, the state word and the forward
151
- // pointer, no decision pending and so no Your Call. The same two parts the
152
- // checkpoint printed for itself, from the same call.
150
+ // The boundary: the orientation-tier ending, the state word, the Stop part
151
+ // where a range just landed, and the forward pointer, no decision pending
152
+ // and so no Your Call. The same parts the checkpoint printed for itself,
153
+ // from the same call.
153
154
  const landed = boundaryEnding(root, slug, current);
154
- return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.pointer]);
155
+ return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.stop, landed.pointer]);
155
156
  }
156
157
  // The pause: the whole turn as one contiguous block, each part a labeled line
157
158
  // with one blank line between, and the only fence the readout's own (plus the
@@ -197,7 +198,7 @@ export function handoff(cwd, args = []) {
197
198
  if (inline) {
198
199
  parts.push(fence('diff', patch.split('\n')).join('\n'));
199
200
  }
200
- parts.push(verdict, nextUpBlock(nextUp, steps.length, where, nextUpNote(intent, buildLog, current)), yourCallBlock(current, checkGreen));
201
+ parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen));
201
202
  const command = reprintCommand('--full', explicit, named);
202
203
  return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
203
204
  }
@@ -267,25 +268,52 @@ function writePause(root, slug, stamp) {
267
268
  }
268
269
  }
269
270
  /**
270
- * The two CLI-rendered parts of a boundary ending: the Verdict folded from
271
- * everything measurable right now, and the pointer at the step to come.
271
+ * The CLI-rendered parts of a boundary ending: the Verdict folded from
272
+ * everything measurable right now, the Stop part when the landing finished a
273
+ * range, and the pointer at the step to come.
272
274
  *
273
275
  * `step` is the step that just landed, or null at a plan commit, where nothing
274
- * was measured and the Verdict vanishes. A transition verb prints these beneath
275
- * its own lead line, so a boundary reads identically whether `checkpoint`
276
- * emitted it or a later `handoff` did; the paths a stray advisory named are the
277
- * one part that does not survive, and the commit holds those.
276
+ * was measured and the Verdict and the Stop part both vanish. A transition verb
277
+ * prints these beneath its own lead line, so a boundary reads identically
278
+ * whether `checkpoint` emitted it or a later `handoff` did; the paths a stray
279
+ * advisory named are the one part that does not survive, and the commit holds
280
+ * those.
278
281
  */
279
282
  export function boundaryEnding(root, slug, step) {
280
283
  const intent = readOr(intentPath(root, slug));
281
284
  const steps = parseOrderedSteps(intent);
282
285
  const nextUp = steps.find((s) => !s.done && s.n !== step);
283
- const note = nextUpNote(intent, readOr(buildLogPath(root, slug)), step);
286
+ const where = relative(root, intentPath(root, slug));
284
287
  return {
285
288
  verdict: step === null ? null : landedVerdict(root, slug, intent, step),
286
- pointer: nextUpBlock(nextUp, steps.length, relative(root, intentPath(root, slug)), note),
289
+ stop: stopLine(intent, steps, step, where),
290
+ pointer: nextUpBlock(intent, readOr(buildLogPath(root, slug)), nextUp, step, where),
287
291
  };
288
292
  }
293
+ /**
294
+ * The Stop part: why the build stops at this boundary, when the step that
295
+ * landed was the last undone step of a range that lands its steps. One labeled
296
+ * line, the range over the count, a plain hyphen, the stop as the plan wrote
297
+ * it, and a bracket pointing at the item, where its note can be read.
298
+ *
299
+ * Null for every other boundary: a landing that leaves the range open, a bare
300
+ * range (its stop was its own pause), a plan without ranges, and the plan
301
+ * commit, where nothing landed.
302
+ */
303
+ function stopLine(intent, steps, step, where) {
304
+ if (step === null) {
305
+ return null;
306
+ }
307
+ const { count, completed } = rangeStanding(parseBuildRanges(intent), steps, step);
308
+ if (completed === null || completed.bare) {
309
+ return null;
310
+ }
311
+ return transition({
312
+ label: 'Stop',
313
+ fact: `Range ${completed.item} of ${count} landed - ${completed.stop}`,
314
+ detail: [`details: \`${where}:${completed.line}\``],
315
+ });
316
+ }
289
317
  /**
290
318
  * The step's record for the build-log's Log: the pause as the human approved
291
319
  * it, with the full story behind each highlight beneath. The Summary and its
@@ -658,10 +686,11 @@ function spikeNextUpLine(inFlight) {
658
686
  // count. The line is a reminder; the dashboard holds the whole sequence.
659
687
  const ORDER_CAP = 5;
660
688
  /**
661
- * The line beneath the forward pointer: the build order from the step it
662
- * names, when `## Build order` re-sequences the plan away from its numbering,
663
- * and the park count, when anything has been parked; '' when neither says
664
- * anything, so a plan nobody re-sequenced with an empty park list renders the
689
+ * The line beneath the forward pointer: the range segment, when the plan
690
+ * declares build ranges; the build order from the step the pointer names, when
691
+ * `## Build order` re-sequences the plan away from its numbering; and the park
692
+ * count, when anything has been parked. '' when none says anything, so a plan
693
+ * with no ranges, nobody re-sequenced, and an empty park list renders the
665
694
  * pointer alone, as before.
666
695
  *
667
696
  * Indented two spaces, because an indented line continues the part above it
@@ -670,9 +699,10 @@ const ORDER_CAP = 5;
670
699
  * `Nothing planned`: an unharvested list at the end of the plan is the one
671
700
  * reminder that matters there.
672
701
  */
673
- function nextUpNote(intent, buildLog, exclude) {
702
+ function nextUpNote(intent, buildLog, exclude, range) {
674
703
  const parks = parseParkTotals(buildLog);
675
704
  const segments = [
705
+ range,
676
706
  buildOrderPhrase(remainingOrder(parseSteps(intent), parseBuildOrder(intent), exclude), ORDER_CAP),
677
707
  parks.total > 0 ? parkedPhrase(parks) : '',
678
708
  ].filter((s) => s.length > 0);
@@ -681,11 +711,41 @@ function nextUpNote(intent, buildLog, exclude) {
681
711
  /**
682
712
  * The forward pointer with its note beneath, as one part: `blocks` keeps a
683
713
  * part's own newlines, so the note rides flush under the line it qualifies.
714
+ *
715
+ * `exclude` is the step the turn is about: the step in flight at a pause, the
716
+ * step just landed at a boundary, null at the plan pause and the plan commit.
717
+ * While the note suggests a range, the bracket bolds the range's model, since
718
+ * the run is the range and that is the `/model` the human picks before typing
719
+ * it.
684
720
  */
685
- function nextUpBlock(nextUp, total, where, note) {
686
- const line = nextUpLine(nextUp, total, where);
721
+ function nextUpBlock(intent, buildLog, nextUp, exclude, where) {
722
+ const steps = parseOrderedSteps(intent);
723
+ const range = rangeSegment(intent, steps, nextUp, exclude);
724
+ const line = nextUpLine(nextUp, steps.length, where, range.model ?? nextUp?.model ?? null);
725
+ const note = nextUpNote(intent, buildLog, exclude, range.segment);
687
726
  return note.length === 0 ? line : `${line}\n${note}`;
688
727
  }
728
+ /**
729
+ * The range segment that leads the note, and the model the pointer's bracket
730
+ * bolds while it suggests a range: `range 3 of 5: /plumbbob:build 6-9` with
731
+ * the range's model, the re-cut words for a flagged range, the words for a step
732
+ * no range names, or '' for a plan without ranges.
733
+ *
734
+ * The step the turn is about reads as landed, so a step pause names what is
735
+ * left of the range once the step in flight lands. A flagged range, and a
736
+ * range with no `- model:` line, leave the step's model in the bracket.
737
+ */
738
+ function rangeSegment(intent, steps, nextUp, exclude) {
739
+ const landed = steps.map((s) => (s.n === exclude ? { ...s, done: true } : s));
740
+ const { count, holding, command, flag } = rangeStanding(parseBuildRanges(intent), landed, nextUp?.n ?? null);
741
+ if (holding !== null && command !== null) {
742
+ return { segment: `range ${holding.item} of ${count}: ${command}`, model: holding.model };
743
+ }
744
+ if (flag === null) {
745
+ return { segment: '', model: null };
746
+ }
747
+ return { segment: holding === null ? flag : `range ${holding.item} of ${count} needs a re-cut (${flag})`, model: null };
748
+ }
689
749
  /**
690
750
  * The forward pointer: the next undone step with the progress count, its
691
751
  * title, and a closing bracket carrying its advisory `- model:` recommendation
@@ -698,12 +758,12 @@ function nextUpBlock(nextUp, total, where, note) {
698
758
  * reads as a path in a PR diff. The line alone is the driver tier's pointer;
699
759
  * every other tier rides `nextUpNote` beneath it, through `nextUpBlock`.
700
760
  */
701
- function nextUpLine(nextUp, total, where) {
761
+ function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
702
762
  if (nextUp === undefined) {
703
763
  return '**Next Up**: Nothing planned - /plumbbob:step or /plumbbob:finish';
704
764
  }
705
765
  const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
706
- const token = modelToken(nextUp.model);
766
+ const token = modelToken(model);
707
767
  const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
708
768
  bracket.push(`details: \`${where}:${nextUp.line}\``);
709
769
  return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
@@ -14,16 +14,18 @@ import { readFileSync, writeFileSync } from 'node:fs';
14
14
  import { findRepoRoot } from "../lib/git.js";
15
15
  import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
16
16
  import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
17
+ import { expandStepList, parseStepList } from "../lib/steplist.js";
17
18
  import { ending, notice, printEnding, transition } from "../lib/notice.js";
18
19
  import { driverPointer } from "./handoff.js";
19
20
  /**
20
- * Set the build order to the given step numbers, the rest of the undone steps
21
- * following in document order, or clear it with `--reset`.
21
+ * Set the build order to the given step numbers and spans, the rest of the
22
+ * undone steps following in document order, or clear it with `--reset`.
22
23
  *
23
- * The line written is the whole remaining sequence, so a cold reader of
24
- * intent.md sees every undone step and not only the ones re-sequenced.
25
- * Refuses (exit 1) with no session, no numbers, a token that is not a number,
26
- * a number the plan lacks, a repeated number, or a step already checkpointed.
24
+ * The line written is the whole remaining sequence, each span spelled out, so a
25
+ * cold reader of intent.md sees every undone step and not only the ones
26
+ * re-sequenced. Refuses (exit 1) with no session, no numbers, a token that is
27
+ * neither a number nor a span, a number the plan lacks, a repeated number, or a
28
+ * step already checkpointed.
27
29
  */
28
30
  export function order(cwd, args) {
29
31
  const root = findRepoRoot(cwd);
@@ -45,8 +47,9 @@ export function order(cwd, args) {
45
47
  });
46
48
  return 0;
47
49
  }
48
- // Numbers arrive space- or comma-separated: `order 7 8 5` and `order 7,8,5`
49
- // read the same, since the line they write is the comma form.
50
+ // Numbers and spans arrive space- or comma-separated, with or without a space
51
+ // after a comma: `order 7 8 5`, `order 7,8,5`, and `order 1, 6, 2-4, 7, 5`
52
+ // all read, and a span names its steps in place, since the line is a sequence.
50
53
  const tokens = args
51
54
  .filter((a) => !a.startsWith('--'))
52
55
  .flatMap((a) => a.split(','))
@@ -60,14 +63,15 @@ export function order(cwd, args) {
60
63
  }));
61
64
  return 1;
62
65
  }
63
- const stray = tokens.find((t) => !/^\d+$/.test(t));
66
+ const lists = tokens.map(parseStepList);
67
+ const stray = tokens.find((_, i) => lists[i] === null);
64
68
  if (stray !== undefined) {
65
- process.stderr.write(notice({ fact: `\`${stray}\` is not a step number`, remedy: 'plumbbob order 7 8 5' }));
69
+ process.stderr.write(notice({ fact: `\`${stray}\` is not a step number or span`, remedy: 'plumbbob order 1, 6, 2-4, 7, 5' }));
66
70
  return 1;
67
71
  }
68
- const given = tokens.map(Number);
69
72
  const steps = parseSteps(intent);
70
73
  const planned = steps.map((s) => s.n);
74
+ const given = expandStepList(lists.flatMap((list) => list ?? []), (n) => planned.includes(n));
71
75
  const unknown = given.find((n) => !planned.includes(n));
72
76
  if (unknown !== undefined) {
73
77
  process.stderr.write(notice({
@@ -18,7 +18,7 @@
18
18
  // deleting them is a gamble the human should take by hand.
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import { findRepoRoot } from "../lib/git.js";
21
- import { parseBuildOrder, parseSteps } from "../lib/orient.js";
21
+ import { parseBuildOrder, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding } from "../lib/orient.js";
22
22
  import { activeBuild, checkpointsPath, clearHandoff, clearTick, grantPath, handoffPath, hasSession, intentPath, inSpike, listBuilds, readTurn, setActiveBuild, setGrant, stepPath, tickPath, } from "../lib/sidecar.js";
23
23
  import { spikeBranches, spikeWorktrees } from "./spike.js";
24
24
  /**
@@ -94,6 +94,7 @@ function sessionFindings(root) {
94
94
  const stepInFlight = existsSync(stepPath(root, slug));
95
95
  findings.push(...phaseFindings(root, slug, stepInFlight));
96
96
  findings.push(...orderFindings(root, slug));
97
+ findings.push(...rangeFindings(root, slug));
97
98
  findings.push(...leftoverFindings(root, slug, stepInFlight));
98
99
  return findings;
99
100
  }
@@ -126,6 +127,35 @@ function orderFindings(root, slug) {
126
127
  },
127
128
  ];
128
129
  }
130
+ /**
131
+ * A build range naming a step the plan does not hold.
132
+ *
133
+ * The section under `## Build ranges` is hand-editable, and a refine can drop
134
+ * a step a range names; the card then suggests no command for that range, and
135
+ * the dashboard says why. Reported beside the build order's own finding, never
136
+ * repaired: where the stop belongs now is the human's call. Silent while the
137
+ * plan declares no ranges.
138
+ */
139
+ function rangeFindings(root, slug) {
140
+ const intent = readOr(intentPath(root, slug));
141
+ const plan = parseBuildRanges(intent);
142
+ if (plan.ranges.length === 0) {
143
+ return [];
144
+ }
145
+ const missing = rangeStanding(plan, parseOrderedSteps(intent), null).drift.filter((d) => d.kind === 'missing');
146
+ if (missing.length === 0) {
147
+ const count = `${plan.ranges.length} range${plan.ranges.length === 1 ? '' : 's'}`;
148
+ return [{ name: 'build ranges', ok: true, detail: `every step they name is planned (${count})` }];
149
+ }
150
+ return [
151
+ {
152
+ name: 'build ranges',
153
+ ok: false,
154
+ detail: missing.map((d) => `range ${d.item} names a step the plan lacks: ${d.words}`).join('; '),
155
+ hint: 're-cut the range under ## Build ranges by hand, or with /plumbbob:refine',
156
+ },
157
+ ];
158
+ }
129
159
  /**
130
160
  * The cursor names a build that is not there.
131
161
  *