plumbbob 0.10.3 → 0.11.0

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.
@@ -3,21 +3,23 @@
3
3
  // one suggested next move. Pure and best-effort by design: it takes raw file
4
4
  // contents (no fs), and a malformed document degrades to fewer fields rather
5
5
  // than throwing.
6
- import { parseStepSeam } from "./intent.js";
6
+ import { parseStepSeam, scopeDrift } from "./intent.js";
7
7
  /**
8
- * The lines of a named `## Section`, from its heading to the next `## ` (or EOF).
8
+ * The lines of a named `## Section`, from its heading to the next `## ` (or
9
+ * EOF), with the offset of the first line so a caller can name an absolute
10
+ * line number in the document rather than one relative to the section.
9
11
  */
10
12
  function sectionLines(content, heading) {
11
13
  const lines = content.split('\n');
12
14
  const start = lines.findIndex((l) => l.trim() === heading);
13
15
  if (start === -1) {
14
- return [];
16
+ return { offset: 0, lines: [] };
15
17
  }
16
18
  let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
17
19
  if (end === -1) {
18
20
  end = lines.length;
19
21
  }
20
- return lines.slice(start + 1, end);
22
+ return { offset: start + 1, lines: lines.slice(start + 1, end) };
21
23
  }
22
24
  /**
23
25
  * The build title: the first `# ` heading in intent.md, or null when absent.
@@ -42,7 +44,7 @@ export function parseTitle(intent) {
42
44
  * narrative roadmap prose lives in its own section and never lands here.
43
45
  */
44
46
  export function parseSteps(intent) {
45
- const lines = sectionLines(intent, '## Steps');
47
+ const { offset, lines } = sectionLines(intent, '## Steps');
46
48
  const starts = [];
47
49
  lines.forEach((line, idx) => {
48
50
  const m = /^(\d+)\.\s+\[([ xX])\]\s+(.*)$/.exec(line);
@@ -65,7 +67,7 @@ export function parseSteps(intent) {
65
67
  const doneWhen = dw ? (dw[1] ?? '').trim() : null;
66
68
  const md = /^\s*-\s*model:\s*(.+)$/im.exec(block);
67
69
  const model = md ? (md[1] ?? '').trim() : null;
68
- return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model };
70
+ return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model, line: offset + s.idx + 1 };
69
71
  });
70
72
  }
71
73
  /**
@@ -110,7 +112,7 @@ export function markStepDone(intent, n) {
110
112
  */
111
113
  export function parseOpenQuestions(intent) {
112
114
  const opener = /^-\s+(?:<a id="[^"]*"><\/a>\s*)?\*{0,2}Q\d+(?: \([^)]+\))?\*{0,2}:\s*(.*)$/;
113
- return sectionLines(intent, '## Open questions').filter((l) => {
115
+ return sectionLines(intent, '## Open questions').lines.filter((l) => {
114
116
  const body = opener.exec(l.trim())?.[1];
115
117
  return body !== undefined && !/\bresolved\b/i.test(l) && !/^<[^>]*>/.test(body);
116
118
  }).length;
@@ -124,7 +126,18 @@ export function parseOpenQuestions(intent) {
124
126
  * never match.
125
127
  */
126
128
  export function parseParked(buildLog) {
127
- return sectionLines(buildLog, '## Park list').filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
129
+ return sectionLines(buildLog, '## Park list').lines.filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
130
+ }
131
+ /**
132
+ * Count the constraints declared under `## Constraints`: the section's
133
+ * top-level list items, in whatever anchored or glossed form they are written.
134
+ *
135
+ * This is the readout's denominator for the constraints row. A constraint
136
+ * always applies (that is what makes it one), so the count is the CLI's to read
137
+ * rather than the model's to attest.
138
+ */
139
+ export function parseConstraintCount(intent) {
140
+ return sectionLines(intent, '## Constraints').lines.filter((l) => /^-\s+\S/.test(l)).length;
128
141
  }
129
142
  /**
130
143
  * The last `step N <sha>` line in the checkpoints ledger, or null when no step
@@ -267,6 +280,422 @@ export function orient(input) {
267
280
  outOfBand: input.outOfBand,
268
281
  };
269
282
  }
283
+ /** The recap's five measuring rows, in the fixed order the fold walks them. */
284
+ const RECAP_ROW_NAMES = ['check', 'done-when', 'decisions', 'constraints', 'seam'];
285
+ /**
286
+ * Classify one recap row's value by its leading word, per the closed vocabulary
287
+ * in docs/presentation.md's row table. Null when the value opens with none of
288
+ * the words the row's kind allows (an unparseable row is simply absent from
289
+ * the fold, the same as a row that never rode the recap at all).
290
+ */
291
+ function classifyRecapRow(name, value) {
292
+ const v = value.trim();
293
+ const test = (re, verdict) => {
294
+ const m = re.exec(v);
295
+ return m === null ? null : { verdict, word: m[0] };
296
+ };
297
+ switch (name) {
298
+ case 'check':
299
+ return test(/^green\b/, 'true') ?? test(/^red\b/, 'failing') ?? test(/^error\b/, 'failing');
300
+ case 'done-when':
301
+ return test(/^met\b/, 'true') ?? test(/^not met\b/, 'failing') ?? test(/^drift\b/, 'drift');
302
+ case 'decisions':
303
+ return (test(/^honored\b/, 'true') ??
304
+ test(/^\d+ of \d+ honored\b/, 'true') ??
305
+ test(/^none exercised\b/, 'true') ??
306
+ test(/^bent\b/, 'failing') ??
307
+ test(/^drift\b/, 'drift'));
308
+ case 'constraints':
309
+ return (test(/^all honored\b/, 'true') ??
310
+ test(/^\d+ of \d+ honored\b/, 'true') ??
311
+ test(/^bent\b/, 'failing') ??
312
+ test(/^drift\b/, 'drift'));
313
+ case 'seam':
314
+ return test(/^held\b/, 'true') ?? test(/^strayed\b/, 'failing') ?? test(/^drift\b/, 'drift');
315
+ }
316
+ }
317
+ /**
318
+ * Parse the fenced recap the model writes into `.plumbbob/detail.md`: the
319
+ * `── recap · step N of M ──` header, the (subset of) five measuring rows
320
+ * present, and the free-text diff row. Null when no header is found.
321
+ *
322
+ * A row absent from the block is simply missing from `rows`: a vanished row
323
+ * the fold treats exactly like one that never applied, never like a failure.
324
+ * An indented `- ` or `→ ` line attaches to the row above it, which is how a
325
+ * row carrying two or more items, or a pointer at its evidence, survives the
326
+ * round trip through the file.
327
+ */
328
+ export function parseRecap(detail) {
329
+ const lines = detail.split('\n');
330
+ const headerIdx = lines.findIndex((l) => /^──\s*recap\s*·\s*step\s+\d+\s+of\s+\d+\s*──$/.test(l.trim()));
331
+ if (headerIdx === -1) {
332
+ return null;
333
+ }
334
+ const header = /step\s+(\d+)\s+of\s+(\d+)/.exec(lines[headerIdx] ?? '');
335
+ if (header === null) {
336
+ return null;
337
+ }
338
+ const rows = {};
339
+ let diff = null;
340
+ let last = null;
341
+ for (let i = headerIdx + 1; i < lines.length; i++) {
342
+ const line = lines[i] ?? '';
343
+ if (line.trim().length === 0) {
344
+ break; // the fence is one contiguous block; a blank line ends it
345
+ }
346
+ const carried = /^\s+([-→])\s+(\S.*)$/.exec(line);
347
+ const open = last === null ? undefined : rows[last];
348
+ if (carried !== null) {
349
+ if (last !== null && open !== undefined) {
350
+ const item = (carried[2] ?? '').trim();
351
+ rows[last] = carried[1] === '→' ? { ...open, pointer: item } : { ...open, items: [...(open.items ?? []), item] };
352
+ }
353
+ continue;
354
+ }
355
+ const m = /^(\S[\w-]*)\s+(.+)$/.exec(line);
356
+ if (m === null) {
357
+ continue;
358
+ }
359
+ const label = m[1] ?? '';
360
+ const value = (m[2] ?? '').trim();
361
+ last = null;
362
+ if (label === 'diff') {
363
+ diff = value;
364
+ }
365
+ else if (RECAP_ROW_NAMES.includes(label)) {
366
+ const classified = classifyRecapRow(label, value);
367
+ if (classified !== null) {
368
+ rows[label] = { ...classified, evidence: value };
369
+ last = label;
370
+ }
371
+ }
372
+ }
373
+ return { step: Number(header[1]), total: Number(header[2]), rows, diff };
374
+ }
375
+ const PLUMB = { glyph: '●', state: 'Plumb' };
376
+ const A_HAIR_OFF = { glyph: '◐', state: 'A hair off' };
377
+ const OUT_OF_PLUMB = { glyph: '○', state: 'Out of plumb' };
378
+ const NOT_STANDING = { glyph: '✗', state: 'Not standing' };
379
+ /**
380
+ * Fold the recap's measuring rows worst-of with the step's accrued stats:
381
+ * drift beats a live failure beats an advisory beats plumb, and each rung
382
+ * names the one component that earned it. The advisory rung runs in a fixed
383
+ * order, the seam stray first.
384
+ */
385
+ export function foldVerdict(rows, stats) {
386
+ for (const name of RECAP_ROW_NAMES) {
387
+ if (rows[name]?.verdict === 'drift') {
388
+ return { ladder: NOT_STANDING, worst: `${name} drifted` };
389
+ }
390
+ }
391
+ for (const name of RECAP_ROW_NAMES) {
392
+ const row = rows[name];
393
+ if (row !== undefined && row.verdict === 'failing') {
394
+ return { ladder: OUT_OF_PLUMB, worst: `${name} ${row.word}` };
395
+ }
396
+ }
397
+ if (stats.driftWarnings > 0) {
398
+ // First of the advisory rung, and uncounted: the stat bumps once per
399
+ // checkpoint rather than once per stray path, and this is the one advisory
400
+ // that asks a question of the plan rather than of the work.
401
+ return { ladder: A_HAIR_OFF, worst: 'staged outside the seam' };
402
+ }
403
+ if (stats.redChecks > 0) {
404
+ return { ladder: A_HAIR_OFF, worst: `${stats.redChecks} red run${stats.redChecks === 1 ? '' : 's'} before green` };
405
+ }
406
+ if (stats.reverts > 0) {
407
+ return { ladder: A_HAIR_OFF, worst: `${stats.reverts} revert${stats.reverts === 1 ? '' : 's'} on this step` };
408
+ }
409
+ if (stats.outOfBand > 0) {
410
+ return { ladder: A_HAIR_OFF, worst: `${stats.outOfBand} commit${stats.outOfBand === 1 ? '' : 's'} outside the ledger` };
411
+ }
412
+ return { ladder: PLUMB, worst: null };
413
+ }
414
+ // --- The assembled readout: the CLI-computed rows and the fence they ride in
415
+ // (docs/presentation.md). `handoff` measures (the check summary, the numstat,
416
+ // the seam file, the stats receipt) and this section turns each measurement
417
+ // into its row, every value cut to the 67 columns the 13-character label pad
418
+ // leaves inside the 80-column fence. ---
419
+ /** The label pad: readout values start at column 14, so a value gets 67 of the 80. */
420
+ const RECAP_VALUE_BUDGET = 67;
421
+ /** The continuation indent: a row's `- ` items and its `→ ` pointer align under the value column. */
422
+ const RECAP_INDENT = ' '.repeat(13);
423
+ /** Where a red check row points when no single slot owns the failure. */
424
+ const CHECK_SUMMARY = '.check/summary.json';
425
+ /**
426
+ * The measured check row from the last run's summary: the verdict word and a
427
+ * count that sizes the gate. A narrowed run records its deselected slots as
428
+ * skipped entries and the row names them behind the separator, degrading to
429
+ * their count when the names overflow the value budget.
430
+ *
431
+ * Red expands where green collapses: one failing slot is named in the row and
432
+ * its raw output is the `→` pointer, and two or more collapse to a count with
433
+ * one `- ` line per slot.
434
+ */
435
+ export function summaryCheckRow(summary) {
436
+ const ran = summary.checks.filter((c) => c.skipped !== true);
437
+ const skipped = summary.checks.filter((c) => c.skipped === true).map((c) => c.name);
438
+ const named = skipped.length === 0 ? '' : ` · without ${skipped.join(', ')}`;
439
+ const counted = skipped.length === 0 ? '' : ` · without ${skipped.length} others`;
440
+ const fit = (long, short) => (long.length <= RECAP_VALUE_BUDGET ? long : short);
441
+ if (summary.ok) {
442
+ const size = `${ran.length} of ${summary.checks.length} checks`;
443
+ return { verdict: 'true', word: 'green', evidence: fit(`green: ${size}${named}`, `green: ${size}${counted}`) };
444
+ }
445
+ const failing = ran.filter((c) => !c.ok);
446
+ const first = failing[0];
447
+ if (failing.length > 1) {
448
+ return {
449
+ verdict: 'failing',
450
+ word: 'red',
451
+ evidence: `red: ${failing.length} of ${ran.length} checks failing`,
452
+ items: failing.map((c) => c.name),
453
+ pointer: CHECK_SUMMARY,
454
+ };
455
+ }
456
+ if (first !== undefined) {
457
+ return {
458
+ verdict: 'failing',
459
+ word: 'red',
460
+ evidence: fit(`red: ${first.name} failing${named}`, `red: ${first.name} failing`),
461
+ pointer: first.output_file == null ? CHECK_SUMMARY : `.check/${first.output_file}`,
462
+ };
463
+ }
464
+ return { verdict: 'failing', word: 'red', evidence: fit(`red${named}`, 'red'), pointer: CHECK_SUMMARY };
465
+ }
466
+ /**
467
+ * The measured seam row: the changed work-plane paths against the step's seam
468
+ * tokens. Null when there is no seam to measure against or nothing changed
469
+ * (the row then vanishes, or the model's attested row stands).
470
+ *
471
+ * Green sizes the declared seam: how many of its tokens the diff actually
472
+ * touched, and that nothing landed outside them. Red states the size of the
473
+ * problem and lets the paths themselves be the evidence: one stray is the `→`
474
+ * pointer, and several break onto `- ` lines. `paths` are work-plane changes,
475
+ * artifacts already filtered by the caller.
476
+ */
477
+ export function seamRowFromDiff(paths, seam) {
478
+ if (seam.length === 0 || paths.length === 0) {
479
+ return null;
480
+ }
481
+ const outside = scopeDrift(paths, seam);
482
+ if (outside.length === 0) {
483
+ const touched = seam.filter((token) => paths.some((p) => scopeDrift([p], [token]).length === 0)).length;
484
+ return { verdict: 'true', word: 'held', evidence: `held: ${touched} of ${seam.length} declared, no strays` };
485
+ }
486
+ const evidence = `strayed: ${outside.length} path${outside.length === 1 ? '' : 's'} outside the seam`;
487
+ const only = outside[0];
488
+ return outside.length === 1 && only !== undefined
489
+ ? { verdict: 'failing', word: 'strayed', evidence, pointer: only }
490
+ : { verdict: 'failing', word: 'strayed', evidence, items: outside };
491
+ }
492
+ /**
493
+ * Sum numstat entries into the diff row's three counts.
494
+ */
495
+ export function countDiff(entries) {
496
+ return {
497
+ added: entries.reduce((sum, e) => sum + e.added, 0),
498
+ removed: entries.reduce((sum, e) => sum + e.removed, 0),
499
+ files: entries.length,
500
+ };
501
+ }
502
+ /**
503
+ * The diff row's value: `+<added> -<removed> across <N> files`, plus the
504
+ * `inline below` pointer when the patch rides the turn. Null when nothing
505
+ * changed: information only, and no change is no information.
506
+ */
507
+ export function diffRowValue(counts, inline) {
508
+ if (counts.files === 0) {
509
+ return null;
510
+ }
511
+ const files = `${counts.files} file${counts.files === 1 ? '' : 's'}`;
512
+ return `+${counts.added} -${counts.removed} across ${files}${inline ? ' · inline below' : ''}`;
513
+ }
514
+ /**
515
+ * The `spent` row's value: what the step has consumed, read from what
516
+ * stats.json and the turn ledger already hold. Null when nothing is countable
517
+ * yet, which vanishes the row on a fresh session rather than printing zeroes.
518
+ *
519
+ * Tokens and cost stay out: the transcript is host-specific and a price table
520
+ * goes stale, while every fact here is on disk already.
521
+ */
522
+ export function spentRowValue(input) {
523
+ const parts = [];
524
+ const elapsed = elapsedSpent(input);
525
+ if (elapsed !== null) {
526
+ parts.push(elapsed);
527
+ }
528
+ if (input.turns !== null && input.turns > 0) {
529
+ parts.push(`${input.turns} turn${input.turns === 1 ? '' : 's'}`);
530
+ }
531
+ if (input.gateMs !== null) {
532
+ parts.push(`${Math.round(input.gateMs / 1000)}s gate`);
533
+ }
534
+ if (input.redChecks > 0) {
535
+ parts.push(`${input.redChecks} red run${input.redChecks === 1 ? '' : 's'}`);
536
+ }
537
+ else if (input.gateMs !== null) {
538
+ parts.push('green first run');
539
+ }
540
+ if (input.driftWarnings > 0) {
541
+ parts.push(`${input.driftWarnings} drift warning${input.driftWarnings === 1 ? '' : 's'}`);
542
+ }
543
+ return parts.length === 0 ? null : parts.join(' · ');
544
+ }
545
+ /**
546
+ * The elapsed clause: the step's `startedAt` to its `landedAt` at the
547
+ * boundary, or to now while it is still open. Null when no stamp parses, or
548
+ * the span runs backwards (a hand-edited receipt), since a negative age is
549
+ * worse than a missing one.
550
+ */
551
+ function elapsedSpent(input) {
552
+ const started = input.startedAt === undefined ? Number.NaN : Date.parse(input.startedAt);
553
+ if (!Number.isFinite(started)) {
554
+ return null;
555
+ }
556
+ const landed = input.landedAt === undefined ? Number.NaN : Date.parse(input.landedAt);
557
+ const ms = (Number.isFinite(landed) ? landed : input.now) - started;
558
+ if (ms < 0) {
559
+ return null;
560
+ }
561
+ if (ms < 60_000) {
562
+ return `${Math.round(ms / 1000)}s`;
563
+ }
564
+ const minutes = Math.round(ms / 60_000);
565
+ return minutes < 120 ? `${minutes} min` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
566
+ }
567
+ /**
568
+ * Collapse a green row to the count that sizes its universe; a failing row
569
+ * comes back untouched, since red is where a row expands to name its offender.
570
+ *
571
+ * `done-when` collapses to its bare word (the Summary above is its evidence),
572
+ * `decisions` to the tags the model listed, and `constraints` to the number
573
+ * declared in intent.md, which the CLI counts rather than trusting the model's
574
+ * arithmetic. The measured rows arrive collapsed already.
575
+ */
576
+ function collapseRow(name, row, constraints) {
577
+ if (row.verdict !== 'true') {
578
+ return row;
579
+ }
580
+ switch (name) {
581
+ case 'done-when':
582
+ return { ...row, evidence: row.word };
583
+ case 'decisions': {
584
+ const tags = new Set(row.evidence.match(/\b[A-Z]\d+\b/g) ?? []);
585
+ return tags.size === 0 ? row : { ...row, evidence: `${tags.size} of ${tags.size} honored` };
586
+ }
587
+ case 'constraints':
588
+ return constraints === 0 ? row : { ...row, evidence: `${constraints} of ${constraints} honored` };
589
+ default:
590
+ return row;
591
+ }
592
+ }
593
+ /**
594
+ * The readout fence's inner lines: the measuring rows present, in their fixed
595
+ * order, then `diff` and `spent`, labels padded so every value starts at
596
+ * column 14. A row's `- ` items and its `→ ` pointer follow it on indented
597
+ * continuation lines.
598
+ *
599
+ * The step's identity is not in here: the `**Readout**:` label above the fence
600
+ * carries it, so it renders once. Empty when no row survived, so the caller can
601
+ * drop the label and the fence together rather than emit an empty instrument.
602
+ */
603
+ export function recapLines(rows, extras) {
604
+ const body = [];
605
+ for (const name of RECAP_ROW_NAMES) {
606
+ const row = rows[name];
607
+ if (row === undefined) {
608
+ continue;
609
+ }
610
+ const shown = collapseRow(name, row, extras.constraints);
611
+ body.push(`${name.padEnd(13)}${shown.evidence}`);
612
+ for (const item of shown.items ?? []) {
613
+ body.push(`${RECAP_INDENT}- ${item}`);
614
+ }
615
+ if (shown.pointer !== undefined) {
616
+ body.push(`${RECAP_INDENT}→ ${shown.pointer}`);
617
+ }
618
+ }
619
+ if (extras.diff !== null) {
620
+ body.push(`${'diff'.padEnd(13)}${extras.diff}`);
621
+ }
622
+ if (extras.spent !== null) {
623
+ body.push(`${'spent'.padEnd(13)}${extras.spent}`);
624
+ }
625
+ return body;
626
+ }
627
+ // --- The detail file's prose: the Summary that opens a decision turn and the
628
+ // recommendation that closes it (docs/presentation.md). The model writes both
629
+ // into `.plumbbob/detail.md` and handoff renders them, so the whole turn is one
630
+ // relayed block and the model authors nothing in the chat. ---
631
+ /**
632
+ * A named section's prose from `.plumbbob/detail.md`, unwrapped: the lines
633
+ * inside a paragraph join into one and blank lines keep their paragraph
634
+ * breaks. Null when the section is absent or holds nothing.
635
+ *
636
+ * The turn's prose flows at the renderer's width, so whatever column the
637
+ * detail file happened to be hard-wrapped to never reaches the turn. The
638
+ * headings are matched case-insensitively: a file written the older lowercase
639
+ * way still parses.
640
+ */
641
+ function sectionProse(detail, heading) {
642
+ const lines = detail.split('\n');
643
+ const start = lines.findIndex((l) => heading.test(l.trim()));
644
+ if (start === -1) {
645
+ return null;
646
+ }
647
+ let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
648
+ if (end === -1) {
649
+ end = lines.length;
650
+ }
651
+ const text = lines
652
+ .slice(start + 1, end)
653
+ .join('\n')
654
+ .trim();
655
+ if (text.length === 0) {
656
+ return null;
657
+ }
658
+ return text
659
+ .split(/\n{2,}/)
660
+ .map((paragraph) => paragraph
661
+ .split('\n')
662
+ .map((l) => l.trim())
663
+ .join(' '))
664
+ .join('\n\n');
665
+ }
666
+ /**
667
+ * The `## Summary` section of `.plumbbob/detail.md` and the numbered detail
668
+ * sections beneath it: the lead prose, then the title of every `## <n>`
669
+ * heading in the order the model wrote them. Null when the file carries no
670
+ * lead, which vanishes the block rather than labeling nothing.
671
+ *
672
+ * The titles pass through as the markdown the model wrote; they are the
673
+ * highlights, and their numbers are the handles `expand` binds to, so each
674
+ * renders under its own number rather than being renumbered by position.
675
+ */
676
+ export function parseSummary(detail) {
677
+ const lead = sectionProse(detail, /^##\s+summary\s*$/i);
678
+ if (lead === null) {
679
+ return null;
680
+ }
681
+ const highlights = [];
682
+ for (const line of detail.split('\n')) {
683
+ const m = /^##\s+(\d+)\s+(\S.*?)\s*$/.exec(line);
684
+ if (m !== null) {
685
+ highlights.push({ n: Number(m[1]), title: m[2] ?? '' });
686
+ }
687
+ }
688
+ return { lead, highlights };
689
+ }
690
+ /**
691
+ * The `## Recommendation` section of `.plumbbob/detail.md`: the one or two
692
+ * plain sentences a decision turn ends on, or null when the model wrote none.
693
+ * `handoff` emits it after the card, unfenced; the eye lands on the last text,
694
+ * and the last text should say which move the model would take.
695
+ */
696
+ export function parseRecommendation(detail) {
697
+ return sectionProse(detail, /^##\s+recommendation\s*$/i);
698
+ }
270
699
  /**
271
700
  * Render an Orientation as the plain-text dashboard `status` prints.
272
701
  */
@@ -319,6 +319,29 @@ export function appendHandoff(root, slug, entry) {
319
319
  export function clearHandoff(root, slug) {
320
320
  rmSync(handoffPath(root, slug), { force: true });
321
321
  }
322
+ // --- The detail plane: `.plumbbob/detail.md`, one flat per-session file holding
323
+ // the in-flight step's full detail. The model overwrites it before every pause
324
+ // (the recap first, then numbered sections matching the highlights); checkpoint
325
+ // folds it into the commit body and then clears it, so the durable archive is
326
+ // the commit and no stale detail piles up. Flat like STATE/TURN, not per-build:
327
+ // a session drives one build at a time, and a past step's expansion reads the
328
+ // commit, never this file. ---
329
+ /**
330
+ * detail.md: the in-flight step's full detail, at the flat sidecar root.
331
+ */
332
+ export function detailPath(root) {
333
+ return join(root, DIRNAME, 'detail.md');
334
+ }
335
+ /**
336
+ * Clear detail.md once checkpoint has folded it into the commit body; absent is
337
+ * a no-op.
338
+ *
339
+ * The rmSync lives here with the sidecar's other deletions, the same home
340
+ * clearHandoff and clearTick keep.
341
+ */
342
+ export function clearDetail(root) {
343
+ rmSync(detailPath(root), { force: true });
344
+ }
322
345
  /**
323
346
  * stats.json: the per-build stats receipt, beside checkpoints.
324
347
  */
@@ -576,6 +599,11 @@ export function excludeControl(root) {
576
599
  // never writes, and never commits.
577
600
  `${DIRNAME}/TURN`,
578
601
  `${DIRNAME}/GRANT`,
602
+ // The in-flight step's detail file: flat per-session control the model
603
+ // overwrites before every pause. checkpoint folds it into the commit body
604
+ // and then clears it, so its archive is the commit, never a tracked path;
605
+ // excluding it keeps stageAll from ever sweeping it into a step commit.
606
+ `${DIRNAME}/detail.md`,
579
607
  `${DIRNAME}/builds/*/STEP`,
580
608
  `${DIRNAME}/builds/*/SEAM`,
581
609
  `${DIRNAME}/builds/*/SPIKE`,
@@ -19,6 +19,8 @@ import { bumpStepStat, buildLogPath, clearHandoff, clearSeam, clearStep, clearTi
19
19
  import { checkLatch } from "../lib/latch.js";
20
20
  import { abandonLogLine, appendToSection } from "../lib/buildlog.js";
21
21
  import { parseSteps } from "../lib/orient.js";
22
+ import { ending, notice, transition } from "../lib/notice.js";
23
+ import { driverPointer } from "./handoff.js";
22
24
  /**
23
25
  * Drop the in-flight step while keeping its work: latch, clear the markers,
24
26
  * record the event, and return to the boundary.
@@ -29,13 +31,13 @@ import { parseSteps } from "../lib/orient.js";
29
31
  export function abandon(cwd, args) {
30
32
  const root = findRepoRoot(cwd);
31
33
  if (root === null || !hasSession(root)) {
32
- process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
34
+ process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
33
35
  return 1;
34
36
  }
35
37
  const { build: slug } = resolveBuild(root, args);
36
38
  const step = readInFlightStep(root, slug);
37
39
  if (step === null) {
38
- process.stderr.write('plumbbob: no step in flight to abandon. abandon drops an in-flight step; from the boundary there is nothing to drop.\n');
40
+ process.stderr.write(notice({ fact: 'no step in flight to abandon', detail: ['abandon drops an in-flight step'] }));
39
41
  return 1;
40
42
  }
41
43
  // The same latch checkpoint runs, keyed on this step: a step exit is a boundary
@@ -55,7 +57,16 @@ export function abandon(cwd, args) {
55
57
  clearHandoff(root, slug);
56
58
  bumpStepStat(root, slug, step, 'abandons');
57
59
  logAbandon(root, slug, step);
58
- process.stdout.write(`plumbbob: step ${step} abandoned — work kept in the tree, back at the boundary. The step stays planned.\n`);
60
+ // A step exit: with the step back at `[ ]` and its markers gone, the pointer
61
+ // aims forward from the boundary, at the very step just dropped.
62
+ process.stdout.write(ending({
63
+ lead: transition({
64
+ label: 'Abandoned',
65
+ fact: `Step ${step}`,
66
+ detail: ['work kept in the tree', 'the step stays planned'],
67
+ }),
68
+ pointer: driverPointer(root, slug),
69
+ }));
59
70
  return 0;
60
71
  }
61
72
  /**
@@ -68,12 +79,18 @@ export function abandon(cwd, args) {
68
79
  */
69
80
  function abandonPause(reason, step) {
70
81
  if (reason === 'ceiling') {
71
- return `plumbbob: abandon refused — step ${step} is past the range you granted; pause here, then run it again to continue.\n`;
82
+ return notice({
83
+ fact: 'abandon refused',
84
+ detail: [`step ${step} is past the range you granted`],
85
+ remedy: 'pause here, then run it again to continue',
86
+ });
72
87
  }
73
- return `plumbbob: abandon refused — no human turn since this step began. A step exit crosses the same
74
- boundary as a checkpoint, so abandon honors the same pause: end the turn, and the human's approval on
75
- their next turn is what lets it land. (An explicit \`/plumbbob:build --auto\` or a step range in the
76
- human's own prompt is the only self-approval.)
88
+ // The fact rides the register; the paragraph under it stays, because the
89
+ // pause affordance is an explanation, not a one-liner, and it mirrors the
90
+ // one the checkpoint latch prints.
91
+ return `${notice({ fact: 'abandon refused', detail: ['no human turn since this step began'] })}A step exit crosses the same boundary as a checkpoint, so abandon honors the same pause: end the
92
+ turn, and the human's approval on their next turn is what lets it land. (An explicit
93
+ \`/plumbbob:build --auto\` or a step range in the human's own prompt is the only self-approval.)
77
94
  `;
78
95
  }
79
96
  /**