plumbbob 0.12.1 → 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.
@@ -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 : [],
@@ -903,6 +1156,45 @@ export function parseSummary(detail) {
903
1156
  }
904
1157
  return { lead, highlights };
905
1158
  }
1159
+ /**
1160
+ * `.plumbbob/detail.md` with a blank line around every heading, and everything
1161
+ * else as written.
1162
+ *
1163
+ * A model copying the detail template can run a heading straight into its body
1164
+ * or its predecessor, and markdownlint's MD022 fails that file wherever
1165
+ * `.plumbbob/` is linted, which turns a downstream checkpoint red. Heading lines
1166
+ * inside a fenced block are content, not headings, so a fence is left whole.
1167
+ * Pure and idempotent: text that already has its blank lines comes back byte for
1168
+ * byte, so a caller can compare the result to the input to learn whether
1169
+ * anything needed fixing.
1170
+ */
1171
+ export function normalizeDetailHeadings(detail) {
1172
+ const out = [];
1173
+ let fence = null;
1174
+ let afterHeading = false;
1175
+ for (const line of detail.split('\n')) {
1176
+ if (fence !== null) {
1177
+ const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
1178
+ if (close !== undefined && close.startsWith(fence.char) && close.length >= fence.length) {
1179
+ fence = null;
1180
+ }
1181
+ out.push(line);
1182
+ continue;
1183
+ }
1184
+ const heading = /^ {0,3}#{1,6}(\s|$)/.test(line);
1185
+ const prior = out[out.length - 1];
1186
+ if ((heading || afterHeading) && line.trim() !== '' && prior !== undefined && prior.trim() !== '') {
1187
+ out.push('');
1188
+ }
1189
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
1190
+ if (open !== undefined) {
1191
+ fence = { char: open.charAt(0), length: open.length };
1192
+ }
1193
+ out.push(line);
1194
+ afterHeading = heading;
1195
+ }
1196
+ return out.join('\n');
1197
+ }
906
1198
  /**
907
1199
  * The `## <n>` sections of `.plumbbob/detail.md` with their bodies: the full
908
1200
  * story behind each highlight, in the order the model wrote them. The body is
@@ -985,11 +1277,15 @@ export function formatOrientation(o) {
985
1277
  return detail.length > 0 ? [head, ...detail].join('\n') : head;
986
1278
  });
987
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), ''];
988
1281
  const cp = o.lastCheckpoint;
989
1282
  const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
990
1283
  // A neutral reconciliation note, only when there is something to reconcile:
991
1284
  // commits landed since the last checkpoint that plumbbob's ledger didn't
992
- // record. Informational: the human commits freely, so this never gates.
1285
+ // record. A commit a plumbbob verb makes starts the range, and a hand-made
1286
+ // plan commit drops out on its `plumbbob plan` body marker, so the count is
1287
+ // the same one `handoff` folds into its Verdict. Informational: the human
1288
+ // commits freely, so this never gates.
993
1289
  const receipts = o.outOfBand > 0
994
1290
  ? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
995
1291
  : [];
@@ -1001,6 +1297,7 @@ export function formatOrientation(o) {
1001
1297
  '',
1002
1298
  stepsBlock,
1003
1299
  '',
1300
+ ...rangesBlock,
1004
1301
  cpLine,
1005
1302
  ...receipts,
1006
1303
  ...orderRow,
@@ -1009,3 +1306,22 @@ export function formatOrientation(o) {
1009
1306
  `next → ${o.next}`,
1010
1307
  ].join('\n');
1011
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
+ }