plumbbob 0.13.1 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,18 +33,22 @@
33
33
  // Every tier's ending is emitted here, so no skill has to fake the furniture in
34
34
  // prose: `--plan` renders the plan-pause ending and `--driver` the driver
35
35
  // turn's pointer, the two endings the session state cannot tell apart from the
36
- // ones above. The driver pointer reads the phase it lands in: a spike open over
36
+ // ones above, and `--stop` the stop card that closes a run's range, which reads
37
+ // a boundary the session state cannot tell from a plain one either. The stop
38
+ // card renders once too, under its own stamp in `STOP`, which also carries the
39
+ // turn it printed in for `plumbbob harvest`'s latch. The driver pointer reads the phase it lands in: a spike open over
37
40
  // the step names closing the spike, a step exit (a revert, an abandon, a spike
38
41
  // closed at the boundary) finds nothing in flight and points forward, with no
39
42
  // Verdict either way, since nothing landed.
40
43
  import { createHash } from 'node:crypto';
41
44
  import { readFileSync, writeFileSync } from 'node:fs';
42
- import { join, relative } from 'node:path';
43
- import { PLAN_COMMIT_MARKER, commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
44
- import { blocks, printEnding, transition } from "../lib/notice.js";
45
+ import { basename, join, relative } from 'node:path';
46
+ import { PLAN_COMMIT_MARKER, commitNumstat, commitsBetween, commitsSince, diffNumstat, diffPatch, findRepoRoot, } from "../lib/git.js";
47
+ import { blocks, notice, printEnding, transition } from "../lib/notice.js";
48
+ import { hasStep } from "../lib/steplist.js";
45
49
  import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
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, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeStanding, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
50
+ import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, pausePath, readStats, readStop, readTurn, resolveBuild, seamPath, stepPath, tickPath, writeStop, } from "../lib/sidecar.js";
51
+ import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, measureRange, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeLanded, rangeSpan, rangeStanding, recapLines, remainingOrder, resolveRun, runBracket, seamRowFromDiff, spentRowValue, stopFollowUp, summaryCheckRow, } from "../lib/orient.js";
48
52
  // The inline-diff threshold (docs/presentation.md): at 20 changed lines or
49
53
  // fewer the patch rides the turn; at 21 it stays in the working tree behind
50
54
  // the counted diff row.
@@ -83,13 +87,17 @@ export function handoff(cwd, args = []) {
83
87
  return 1;
84
88
  }
85
89
  const { build: slug, rest } = resolveBuild(root, args);
86
- if (rest.includes('--plan') && rest.includes('--driver')) {
87
- process.stderr.write('plumbbob: handoff: --plan and --driver name different tiers; pass one.\n');
90
+ const tiers = ['--plan', '--driver', '--stop'].filter((flag) => rest.includes(flag));
91
+ if (tiers.length > 1) {
92
+ process.stderr.write(`plumbbob: handoff: ${tiers.join(' and ')} name different tiers; pass one.\n`);
88
93
  return 1;
89
94
  }
90
95
  // A build named by flag rides any command that reprints this ending, since
91
96
  // the cursor may point elsewhere.
92
97
  const named = args.includes('--build') ? slug : null;
98
+ if (rest.includes('--stop')) {
99
+ return stopCard(root, slug, named, rest.includes('--full'));
100
+ }
93
101
  const intent = readOr(intentPath(root, slug));
94
102
  // In build order, so the pointer names the step a bare `plumbbob build`
95
103
  // would enter; the build-log feeds the park count beneath it.
@@ -109,7 +117,7 @@ export function handoff(cwd, args = []) {
109
117
  const plan = [
110
118
  SEAM_RULE,
111
119
  nextUpBlock(intent, buildLog, first, null, where),
112
- planCallBlock(first),
120
+ planCallBlock(first, cardRun(intent, null)),
113
121
  ];
114
122
  const command = reprintCommand('--plan', undefined, named);
115
123
  return emit(root, { tier: 'decision', render: 'plan', command }, withRecommendation(plan, parseRecommendation(detail)));
@@ -198,7 +206,7 @@ export function handoff(cwd, args = []) {
198
206
  if (inline) {
199
207
  parts.push(fence('diff', patch.split('\n')).join('\n'));
200
208
  }
201
- parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen));
209
+ parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen, cardRun(intent, current)));
202
210
  const command = reprintCommand('--full', explicit, named);
203
211
  return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
204
212
  }
@@ -284,36 +292,400 @@ export function boundaryEnding(root, slug, step) {
284
292
  const steps = parseOrderedSteps(intent);
285
293
  const nextUp = steps.find((s) => !s.done && s.n !== step);
286
294
  const where = relative(root, intentPath(root, slug));
295
+ const landed = landedRange(intent, steps, step);
287
296
  return {
288
297
  verdict: step === null ? null : landedVerdict(root, slug, intent, step),
289
- stop: stopLine(intent, steps, step, where),
298
+ stop: landed === null ? null : stopLead(landed, where),
299
+ range: landed === null ? null : { item: landed.range.item, count: landed.count },
290
300
  pointer: nextUpBlock(intent, readOr(buildLogPath(root, slug)), nextUp, step, where),
291
301
  };
292
302
  }
293
303
  /**
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.
304
+ * The range that lands its steps whose last undone step was `step`, now
305
+ * landed, or null for every other landing: one that leaves its range open, a
306
+ * bare range (its stop was its own pause), a step no range names, a plan
307
+ * without ranges, and the plan commit, where nothing landed.
302
308
  */
303
- function stopLine(intent, steps, step, where) {
309
+ function landedRange(intent, steps, step) {
304
310
  if (step === null) {
305
311
  return null;
306
312
  }
307
313
  const { count, completed } = rangeStanding(parseBuildRanges(intent), steps, step);
308
- if (completed === null || completed.bare) {
309
- return null;
310
- }
314
+ return completed === null || completed.bare ? null : { range: completed, count, step };
315
+ }
316
+ /**
317
+ * The Stop part: why the build stops at this boundary. One labeled line, the
318
+ * range over the count, a plain hyphen, the stop as the plan wrote it, and a
319
+ * bracket pointing at the item, where its note can be read. The stop card
320
+ * opens on the same line, moved to the top of the card.
321
+ */
322
+ function stopLead(landed, where) {
311
323
  return transition({
312
324
  label: 'Stop',
313
- fact: `Range ${completed.item} of ${count} landed - ${completed.stop}`,
314
- detail: [`details: \`${where}:${completed.line}\``],
325
+ fact: `Range ${landed.range.item} of ${landed.count} landed - ${landed.range.stop}`,
326
+ detail: [detailsLink(where, landed.range.line)],
327
+ });
328
+ }
329
+ /**
330
+ * The `details:` clause every bracket on a card carries, in its short linked
331
+ * form: the file's name and line as the link's text, and its path from the
332
+ * repo root with a `#L41` fragment as the target. With no line, the name links
333
+ * the whole file, as the Summary lead's does, since the whole file is the
334
+ * detail.
335
+ *
336
+ * The full path was noise on every card while the name alone still says which
337
+ * file, and a relative target keeps any one machine's path out of a pasted
338
+ * card (D107 (short-linked-paths)).
339
+ */
340
+ export function detailsLink(path, line) {
341
+ const name = basename(path);
342
+ return line === null ? `details: [${name}](${path})` : `details: [${name}:${line}](${path}#L${line})`;
343
+ }
344
+ /**
345
+ * The stop card that closes a run's range (docs/presentation.md): the Stop
346
+ * lead, the range's Summary and highlights from the range's detail file, the
347
+ * Readout measured across the range, the Verdict, the Harvested part while a
348
+ * park is open, Next Up, Your Call, and the recommendation.
349
+ *
350
+ * It renders for the range the last landing completed, and refuses with a
351
+ * step in flight, with no range just landed, and with a detail file written
352
+ * for anything but that range, since a card with none of the model's review
353
+ * on it would ask the human to approve nothing. It renders once per stop:
354
+ * while the STOP stamp matches the range and what the card showed, a later
355
+ * render is the one-line reminder, and `--full` prints the card whatever the
356
+ * stamp says. Every card it prints rewrites the stamp with the turn it printed
357
+ * in, which is the turn `plumbbob harvest` waits past.
358
+ */
359
+ function stopCard(root, slug, named, full) {
360
+ const intent = readOr(intentPath(root, slug));
361
+ const steps = parseOrderedSteps(intent);
362
+ const landed = stopRange(root, slug, intent, steps);
363
+ if (typeof landed === 'string') {
364
+ process.stderr.write(landed);
365
+ return 1;
366
+ }
367
+ const { range, count, step } = landed;
368
+ const detail = settleDetail(root, readOr(detailPath(root)));
369
+ if (parseDetailRange(detail) !== range.item) {
370
+ process.stderr.write(notice({
371
+ fact: 'no stop card to render',
372
+ detail: [`the detail file is not range ${range.item}'s`],
373
+ remedy: `write range ${range.item}'s detail in .plumbbob/detail.md, then plumbbob handoff --stop`,
374
+ }));
375
+ return 1;
376
+ }
377
+ const where = relative(root, intentPath(root, slug));
378
+ const buildLog = readOr(buildLogPath(root, slug));
379
+ const review = stopReview(root, slug, intent, range, detail);
380
+ const parks = parkList(buildLog);
381
+ const harvest = harvestPart(parks, harvestProposals(detail));
382
+ const hash = stopHash(detail, review.stamped, harvest ?? '');
383
+ const stamp = readStop(root, slug);
384
+ if (!full && stamp !== null && stamp.range === range.item && stamp.hash === hash) {
385
+ // The stop the human already read, unchanged: the answer above this line
386
+ // is what they asked for, and the card would push it off the screen.
387
+ const command = reprintCommand('--stop', undefined, named);
388
+ return emit(root, { tier: 'decision', render: 'stop-reminder', command }, [
389
+ `**Next Up**: Back to Range ${range.item} of ${count}'s stop - ${range.stop}`,
390
+ ]);
391
+ }
392
+ writeStop(root, slug, { range: range.item, turn: readTurn(root), hash });
393
+ const nextUp = steps.find((s) => !s.done && s.n !== step);
394
+ const parts = [
395
+ stopLead(landed, where),
396
+ summaryBlock(parseSummary(detail), relative(root, detailPath(root))),
397
+ ];
398
+ if (review.readout.length > 0) {
399
+ parts.push([READOUT_LABEL, '', ...fence('text', review.readout)].join('\n'));
400
+ }
401
+ parts.push(review.verdict);
402
+ if (harvest !== null) {
403
+ parts.push(harvest);
404
+ }
405
+ parts.push(
406
+ // The note carries no parks segment here: the Harvested lead above sizes the list.
407
+ nextUpBlock(intent, '', nextUp, step, where), stopCallBlock(leanExample(parks, harvestProposals(detail)), intent));
408
+ const command = reprintCommand('--stop --full', undefined, named);
409
+ return emit(root, { tier: 'decision', render: 'stop', command }, withRecommendation(parts, parseRecommendation(detail)));
410
+ }
411
+ /**
412
+ * The range the stop card reviews: the one the last landing completed, or the
413
+ * refusal that says why no stop card renders, with the move that does render
414
+ * this moment.
415
+ */
416
+ function stopRange(root, slug, intent, steps) {
417
+ const refuse = (why, remedy) => notice({ fact: 'no stop card to render', detail: [why], remedy });
418
+ const inFlight = readMarker(stepPath(root, slug));
419
+ if (inFlight !== null) {
420
+ return refuse(`step ${inFlight} is in flight`, 'plumbbob handoff renders its pause');
421
+ }
422
+ const last = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
423
+ if (last === null) {
424
+ return refuse('no step has landed', 'plumbbob handoff renders the pointer');
425
+ }
426
+ const landed = landedRange(intent, steps, last.n);
427
+ if (landed === null) {
428
+ const { completed, count } = rangeStanding(parseBuildRanges(intent), steps, last.n);
429
+ const why = completed === null
430
+ ? `step ${last.n} landed last, and completed no range`
431
+ : `range ${completed.item} of ${count} is bare, and its stop was step ${last.n}'s pause`;
432
+ return refuse(why, 'plumbbob handoff renders this boundary');
433
+ }
434
+ return landed;
435
+ }
436
+ /**
437
+ * Measure a landed range and fold its Verdict: the CLI's rows across the
438
+ * range's commits, the ledger, stats.json, and the last check, joined with
439
+ * the three judgment rows the range's detail wrote, the measured ones winning
440
+ * where both exist.
441
+ *
442
+ * The card and the record read the review through this one call, so the Log
443
+ * keeps exactly what the card showed.
444
+ */
445
+ function stopReview(root, slug, intent, range, detail) {
446
+ const measure = rangeMeasure(root, slug, intent, range);
447
+ const rows = { ...(parseRecap(detail, 'range')?.rows ?? {}), ...measure.rows };
448
+ const { ladder, worst } = foldVerdict(rows, measure.stats, 'range');
449
+ return {
450
+ readout: recapLines(rows, {
451
+ steps: measure.steps,
452
+ diff: measure.diff,
453
+ spent: measure.spent,
454
+ constraints: parseConstraintCount(intent),
455
+ }),
456
+ verdict: verdictLine(ladder, worst),
457
+ stamped: recapLines(measure.rows, { steps: measure.steps, diff: measure.diff, spent: null, constraints: 0 }),
458
+ };
459
+ }
460
+ /**
461
+ * Read what a landed range's measures are taken from: each checkpoint's
462
+ * commit the ledger names for a step of the range, the first-parent commits
463
+ * across the range's span for the out-of-band count, stats.json, and the last
464
+ * check's summary.
465
+ */
466
+ function rangeMeasure(root, slug, intent, range) {
467
+ const checkpoints = readOr(checkpointsPath(root, slug));
468
+ const commits = {};
469
+ for (const line of checkpoints.split('\n')) {
470
+ const m = /^step\s+(\d+)\s+(\S+)/.exec(line.trim());
471
+ const sha = m?.[2];
472
+ if (sha !== undefined && hasStep(range.steps, Number(m?.[1]))) {
473
+ commits[sha] = commitNumstat(root, sha);
474
+ }
475
+ }
476
+ const span = rangeSpan(checkpoints, range);
477
+ return measureRange({
478
+ range,
479
+ intent,
480
+ checkpoints,
481
+ stats: readStats(root, slug),
482
+ commits,
483
+ between: span === null ? [] : commitsBetween(root, span.from, span.to, PLAN_COMMIT_MARKER),
484
+ summary: readCheckSummary(root),
315
485
  });
316
486
  }
487
+ /**
488
+ * The stamp's hash of a stop card: the range's detail file, the measured rows
489
+ * as rendered, and the Harvested part, which reads the park list. Never `spent`,
490
+ * as at a pause.
491
+ */
492
+ function stopHash(detail, measured, harvest) {
493
+ const hash = createHash('sha256');
494
+ for (const part of [detail, ...measured, harvest]) {
495
+ hash.update(part).update('\0');
496
+ }
497
+ return hash.digest('hex');
498
+ }
499
+ /**
500
+ * The park list under `build-log.md`'s `## Park list`, each line with its
501
+ * handle: its place in the list, counting every line ever parked, harvested
502
+ * ones included.
503
+ *
504
+ * A line is never deleted from the list, only flipped, so the handle is
505
+ * stable for the life of the build, and it counts the same lines the note's
506
+ * `parked 3 of 8 open` does. The stop card prints the handle and the text
507
+ * from here, and `plumbbob harvest` matches a proposal to its park by the
508
+ * handle alone.
509
+ */
510
+ export function parkList(buildLog) {
511
+ const lines = buildLog.split('\n');
512
+ const start = lines.findIndex((l) => l.trim() === '## Park list');
513
+ if (start === -1) {
514
+ return [];
515
+ }
516
+ const end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
517
+ return lines.slice(start + 1, end === -1 ? lines.length : end).flatMap((line) => {
518
+ const m = /^-\s+\[([ xX])\]\s+(\S.*)$/.exec(line.trim());
519
+ return m === null ? [] : [{ open: m[1] === ' ', text: (m[2] ?? '').trim() }];
520
+ }).map((park, i) => ({ handle: i + 1, ...park }));
521
+ }
522
+ /**
523
+ * The proposals under a detail file's `## Harvest` section, one per
524
+ * `- P6 → tangent: <the reason>` line, in the order written. The handle is the
525
+ * one the card prints (D105 (park-handles)); the `- park 6 →` form a detail
526
+ * written before it carried is still read.
527
+ *
528
+ * The class is read as written, whatever its word: the card shows the
529
+ * proposal the model made, and `plumbbob harvest` is what refuses one outside
530
+ * blocker, tangent, and pivot. A line in any other shape is not a proposal.
531
+ */
532
+ export function harvestProposals(detail) {
533
+ const lines = detail.split('\n');
534
+ const start = lines.findIndex((l) => /^##\s+Harvest\s*$/i.test(l.trim()));
535
+ if (start === -1) {
536
+ return [];
537
+ }
538
+ const end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
539
+ return lines.slice(start + 1, end === -1 ? lines.length : end).flatMap((line) => {
540
+ const m = /^-\s+(?:P|park\s+)(\d+)\s*(?:→|->)\s*([\w-]+)\s*(?::\s*(.*))?$/i.exec(line.trim());
541
+ return m === null ? [] : [{ handle: Number(m[1]), kind: (m[2] ?? '').toLowerCase(), reason: (m[3] ?? '').trim() }];
542
+ });
543
+ }
544
+ /**
545
+ * The range a detail file reviews, read off its `# Detail · Range <n> · <stop>`
546
+ * header, or null when the first line is not a range's header (a step's
547
+ * detail, a boundary harvest's, or a file written without one).
548
+ */
549
+ export function parseDetailRange(detail) {
550
+ const first = detail.trimStart().split('\n', 1)[0] ?? '';
551
+ const m = /^#\s+Detail\s*·\s*Range\s+(\d+)\b/.exec(first);
552
+ return m === null ? null : Number(m[1]);
553
+ }
554
+ /**
555
+ * The stop card still waiting for its reply, or null when none waits: a STOP
556
+ * stamp, a detail file whose header names the stamp's range, and that range
557
+ * still parsing and reading as landed. The record clears the stamp and the
558
+ * detail file, so the wait ends with it.
559
+ *
560
+ * The digest is left to the record's own latch: a detail file rewritten for
561
+ * a re-render that has not run yet still waits, and a stamp from before the
562
+ * digest carries none. The landed condition is what keeps a re-cut from
563
+ * wedging the build, since a range re-cut to take a new step stops waiting.
564
+ * The stamp is the build's own but the detail file is the session's, so in a
565
+ * session of several builds, a stamp left in this build and another build's
566
+ * card for the same range number read as this build's card waiting.
567
+ */
568
+ export function waitingCard(root, slug, intent) {
569
+ const stamp = readStop(root, slug);
570
+ if (stamp === null || parseDetailRange(readOr(detailPath(root))) !== stamp.range) {
571
+ return null;
572
+ }
573
+ const plan = parseBuildRanges(intent);
574
+ const range = plan.ranges.find((r) => r.item === stamp.range);
575
+ if (range === undefined || !rangeLanded(range, parseOrderedSteps(intent))) {
576
+ return null;
577
+ }
578
+ return { range: range.item, count: plan.ranges.length + plan.malformed.length };
579
+ }
580
+ /**
581
+ * The Harvested part: a lead that sizes the list, then one bullet per open
582
+ * park in list order, its `P6` handle and text from the list and, behind the
583
+ * arrow, the lean the detail file proposes: `lean: tangent, <the reason>`. A
584
+ * park the detail left out reads `no lean proposed`, so a silent omission is
585
+ * one on the card. Null with no park open, which renders the card without the
586
+ * part.
587
+ *
588
+ * A bullet, never a numbered item, because a markdown list renumbers: open
589
+ * parks 6 and 8 would render as 6 and 7, and the number read would not be the
590
+ * one the record matches (D105 (park-handles)).
591
+ */
592
+ function harvestPart(parks, proposals) {
593
+ const open = parks.filter((p) => p.open);
594
+ if (open.length === 0) {
595
+ return null;
596
+ }
597
+ const items = open.map((park) => {
598
+ const proposal = proposals.find((p) => p.handle === park.handle);
599
+ const lean = proposal === undefined ? 'no lean proposed' : proposal.reason.length === 0 ? `lean: ${proposal.kind}` : `lean: ${proposal.kind}, ${proposal.reason}`;
600
+ return `- P${park.handle} · ${park.text} → ${lean}`;
601
+ });
602
+ const lead = `**Harvested**: ${open.length} of ${parks.length} parked idea${parks.length === 1 ? '' : 's'} open`;
603
+ return [lead, '', ...items].join('\n');
604
+ }
605
+ /**
606
+ * The stop card's Your Call block: the replies that settle the card in one
607
+ * message, each recording the review and every lean before what it names.
608
+ *
609
+ * While a park is open (`example` set), one line beneath the label says that
610
+ * approving locks in each lean and that changing one means saying so first,
611
+ * with the example naming a handle on the card, since the reply is a decision
612
+ * on every lean at once (D106 (leans-harvested)); the direction row then
613
+ * carries no example of its own. The second row is the move after the stop,
614
+ * read through `stopFollowUp` so the dashboard and `plumbbob build`'s refusal
615
+ * name the same one: the next range's run, the finish on the plan's last
616
+ * range, and nothing when the run would refuse (a flagged range, or a next
617
+ * step no range names), where the note beneath Next Up carries the drift
618
+ * words. With no park open, no line renders, the rows record the review alone,
619
+ * and direction is a change to it.
620
+ */
621
+ function stopCallBlock(example, intent) {
622
+ const parked = example !== null;
623
+ const what = parked ? 'them' : 'it';
624
+ const moves = [
625
+ callLine('`looks good`', parked ? 'I record the review and every lean as proposed, folding a blocker into the plan; back to the boundary' : 'I record the review; back to the boundary'),
626
+ ];
627
+ const follow = stopFollowUp(intent);
628
+ if (follow === 'finish') {
629
+ moves.push(callLine('`/plumbbob:finish`', `I record ${what} the same way, then write the report and close the build`));
630
+ }
631
+ else if (follow !== null) {
632
+ moves.push(callLine('`/plumbbob:run`', `I record ${what} the same way, then run range ${follow.range.item} of ${follow.count} to its stop`));
633
+ }
634
+ moves.push(callLine('anything that reads as direction', `I re-render the card with the ${parked ? 'lean' : 'review'} changed; nothing is recorded until you approve`), callLine('`expand`, or any question', 'I show more of what is there; nothing changes, and `show the stop` brings all of this back'));
635
+ const lockIn = parked ? [`Approving locks in each lean above; to change one, say so first (\`${example}\`).`, ''] : [];
636
+ return ['**Your Call**:', '', ...lockIn, ...moves].join('\n');
637
+ }
638
+ /**
639
+ * The lock-in line's example, a reply naming a park on this card by its
640
+ * handle: the first open park recast as the class its lean is not, a blocker
641
+ * for a tangent, a pivot, or no lean at all, and a tangent for a blocker, so
642
+ * it always names a change the card could take. Null with no park open, where
643
+ * there is no lean to change.
644
+ */
645
+ function leanExample(parks, proposals) {
646
+ const first = parks.find((p) => p.open);
647
+ if (first === undefined) {
648
+ return null;
649
+ }
650
+ const kind = proposals.find((p) => p.handle === first.handle)?.kind;
651
+ return `make P${first.handle} a ${kind === 'blocker' ? 'tangent' : 'blocker'}`;
652
+ }
653
+ /**
654
+ * A stop's record for the Log: the card as the human settled it, with the
655
+ * full story behind each highlight beneath. The Summary and its highlights,
656
+ * the Readout fence, the Verdict, the Harvested part, the recommendation, then
657
+ * the numbered sections' bodies, blank-line separated and unindented; the
658
+ * caller nests it under the range's dated Log line.
659
+ *
660
+ * Measured, not recalled, through the same review the card rendered. The
661
+ * Harvested part reads the park list as `buildLog` holds it, so the caller
662
+ * passes the list from before its record flips the settled parks, and the
663
+ * part shows each class the human confirmed. Null when the detail file
664
+ * reviews no range the plan declares.
665
+ */
666
+ export function stopRecord(root, slug, detail, buildLog) {
667
+ const intent = readOr(intentPath(root, slug));
668
+ const item = parseDetailRange(detail);
669
+ const range = parseBuildRanges(intent).ranges.find((r) => r.item === item);
670
+ if (range === undefined) {
671
+ return null;
672
+ }
673
+ const review = stopReview(root, slug, intent, range, detail);
674
+ const summary = parseSummary(detail);
675
+ const parts = [];
676
+ if (summary !== null) {
677
+ parts.push(recordSummary(summary));
678
+ }
679
+ if (review.readout.length > 0) {
680
+ parts.push([READOUT_LABEL, '', ...fence('text', review.readout)].join('\n'));
681
+ }
682
+ parts.push(review.verdict);
683
+ const harvest = harvestPart(parkList(buildLog), harvestProposals(detail));
684
+ if (harvest !== null) {
685
+ parts.push(harvest);
686
+ }
687
+ return [...withRecommendation(parts, parseRecommendation(detail)), ...sectionBlocks(parseSections(detail))].join('\n\n');
688
+ }
317
689
  /**
318
690
  * The step's record for the build-log's Log: the pause as the human approved
319
691
  * it, with the full story behind each highlight beneath. The Summary and its
@@ -490,7 +862,7 @@ function summaryBlock(summary, where) {
490
862
  if (summary === null) {
491
863
  return '';
492
864
  }
493
- const lead = `**Summary**: ${summary.lead} (details: \`${where}\`)`;
865
+ const lead = `**Summary**: ${summary.lead} (${detailsLink(where, null)})`;
494
866
  if (summary.highlights.length === 0) {
495
867
  return lead;
496
868
  }
@@ -625,11 +997,20 @@ function readCheckSummary(root) {
625
997
  * `revert` comes last, because a destructive move is named rather than
626
998
  * discovered. `looks good` renders only while the measured check is green;
627
999
  * offering a move that would refuse teaches a false ceremony.
1000
+ *
1001
+ * `run` is what `/plumbbob:run` typed at this pause names. Inside a range
1002
+ * that lands its steps, with steps still ahead of this one, the run rides
1003
+ * second as the other approval: it lands this step and the rest of the range.
1004
+ * At a bare range's pause, and at a range's last step, it does no more than
1005
+ * `looks good`, so the block keeps its four moves.
628
1006
  */
629
- function yourCallBlock(step, checkGreen) {
1007
+ function yourCallBlock(step, checkGreen, run) {
630
1008
  const moves = [];
631
1009
  if (checkGreen) {
632
1010
  moves.push(callLine('`looks good`', `I checkpoint Step ${step}; back to the boundary`));
1011
+ if (run.ok && !run.range.bare && run.left[0] === step && run.left.length > 1) {
1012
+ moves.push(callLine('`/plumbbob:run`', `I checkpoint Step ${step}, then run the rest of range ${run.range.item} to its stop`));
1013
+ }
633
1014
  }
634
1015
  moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes, and `show the pause` brings all of this back'));
635
1016
  moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
@@ -649,9 +1030,14 @@ function callLine(move, outcome) {
649
1030
  * there. Nothing is recorded yet, so `revert` has nothing to wind back to and
650
1031
  * vanishes; `looks good` names the step the plan starts at, which is Step 1 at
651
1032
  * the plan pause and the first undone step after a mid-build refine.
1033
+ *
1034
+ * When that step sits in a range the run can walk, `looks good` names the run
1035
+ * instead, since the run is what the human types next; a step in no range, or
1036
+ * in a flagged one, keeps the build.
652
1037
  */
653
- function planCallBlock(first) {
654
- const starts = first === undefined ? '' : `; /plumbbob:build starts Step ${first.n}`;
1038
+ function planCallBlock(first, run) {
1039
+ const build = first === undefined ? '' : `; /plumbbob:build starts Step ${first.n}`;
1040
+ const starts = run.ok ? `; /plumbbob:run runs range ${run.range.item} from Step ${run.left[0]}` : build;
655
1041
  return [
656
1042
  '**Your Call**:',
657
1043
  '',
@@ -727,24 +1113,44 @@ function nextUpBlock(intent, buildLog, nextUp, exclude, where) {
727
1113
  }
728
1114
  /**
729
1115
  * 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.
1116
+ * bolds while it suggests a range: the run `/plumbbob:run` typed now would
1117
+ * walk, `range 3 of 5: /plumbbob:run (steps 6-9)`, with the model of the range
1118
+ * holding the step Next Up names; the re-cut words for a flagged range; the
1119
+ * words for a step no range names; or '' for a plan without ranges.
733
1120
  *
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.
1121
+ * The bracket is the grant the hook would mint. At a step pause that is the
1122
+ * range holding the step in flight, its bracket opening on that step, since a
1123
+ * run typed there lands it first; at the pause on a range's last step, where
1124
+ * a run ends on that range's stop, the segment names no run
1125
+ * (`range 3 of 5 lands with step 9`). The model is read with the step the turn
1126
+ * is about landed, so at that last-step pause it is the next range's, the one
1127
+ * the human picks a model for. A flagged range, and a range with no
1128
+ * `- model:` line, leave the step's model in the bracket.
737
1129
  */
738
1130
  function rangeSegment(intent, steps, nextUp, exclude) {
739
1131
  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 };
1132
+ const { holding, command } = rangeStanding(parseBuildRanges(intent), landed, nextUp?.n ?? null);
1133
+ const model = holding !== null && command !== null ? holding.model : null;
1134
+ const paused = steps.some((s) => s.n === exclude && !s.done) ? exclude : null;
1135
+ const run = cardRun(intent, paused);
1136
+ if (run.ok) {
1137
+ const name = `range ${run.range.item} of ${run.count}`;
1138
+ const lands = paused !== null && run.left.length === 1;
1139
+ return { segment: lands ? `${name} lands with step ${paused}` : `${name}: /plumbbob:run (${runBracket(run)})`, model };
743
1140
  }
744
- if (flag === null) {
745
- return { segment: '', model: null };
1141
+ if (run.kind === 'uncovered') {
1142
+ return { segment: run.words, model };
746
1143
  }
747
- return { segment: holding === null ? flag : `range ${holding.item} of ${count} needs a re-cut (${flag})`, model: null };
1144
+ return { segment: run.kind === 'flagged' ? `range ${run.item} of ${run.count} needs a re-cut (${run.words})` : '', model };
1145
+ }
1146
+ /**
1147
+ * The run `/plumbbob:run` typed now would walk, as the card names it: the
1148
+ * range holding `paused`, the step waiting at its pause, or with none the
1149
+ * range holding the next undone step. A spike open is the run's refusal to
1150
+ * make, not the card's, so the card names the run that waits beyond it.
1151
+ */
1152
+ function cardRun(intent, paused) {
1153
+ return resolveRun({ intent, inFlight: paused, spiking: false, item: null });
748
1154
  }
749
1155
  /**
750
1156
  * The forward pointer: the next undone step with the progress count, its
@@ -753,10 +1159,10 @@ function rangeSegment(intent, steps, nextUp, exclude) {
753
1159
  * instead.
754
1160
  *
755
1161
  * The model is the second bold token the line spends, because it is the one
756
- * the human acts on (a `/model` call) before the next run; the path is a bare
757
- * `path:line` in a code span, the one link form that opens in a host and still
758
- * reads as a path in a PR diff. The line alone is the driver tier's pointer;
759
- * every other tier rides `nextUpNote` beneath it, through `nextUpBlock`.
1162
+ * the human acts on (a `/model` call) before the next run; the path is the
1163
+ * short link every `details:` bracket carries (`detailsLink`). The line alone
1164
+ * is the driver tier's pointer; every other tier rides `nextUpNote` beneath
1165
+ * it, through `nextUpBlock`.
760
1166
  */
761
1167
  function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
762
1168
  if (nextUp === undefined) {
@@ -765,7 +1171,7 @@ function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
765
1171
  const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
766
1172
  const token = modelToken(model);
767
1173
  const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
768
- bracket.push(`details: \`${where}:${nextUp.line}\``);
1174
+ bracket.push(detailsLink(where, nextUp.line));
769
1175
  return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
770
1176
  }
771
1177
  /**