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.
@@ -213,6 +213,21 @@ export function stepPath(root, slug) {
213
213
  export function clearStep(root, slug) {
214
214
  rmSync(stepPath(root, slug), { force: true });
215
215
  }
216
+ /**
217
+ * PAUSE: the stamp of the step pause `handoff` last rendered whole, the step
218
+ * number and a hash of what the card showed. While a later render matches it,
219
+ * handoff prints the one-line reminder instead of the card.
220
+ */
221
+ export function pausePath(root, slug) {
222
+ return join(artifactDir(root, slug), 'PAUSE');
223
+ }
224
+ /**
225
+ * Remove the PAUSE stamp: a checkpoint, a revert, or an abandon ends the pause
226
+ * it recorded, so the next pause renders whole. Absent is a no-op.
227
+ */
228
+ export function clearPause(root, slug) {
229
+ rmSync(pausePath(root, slug), { force: true });
230
+ }
216
231
  /**
217
232
  * The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
218
233
  * commits, one line each.
@@ -458,6 +473,9 @@ export function clearSpike(root, slug) {
458
473
  // `plumbbob turn` (the UserPromptSubmit hook) ticks TURN once per human prompt
459
474
  // and rewrites GRANT from the literal prompt; the checkpoint latch reads them to
460
475
  // refuse a land until a human turn has intervened since the step was entered.
476
+ // A background job's notice reaches the same hook and ticks neither; PROMPT,
477
+ // beside them, records the id of the last prompt the hook saw, so the hook can
478
+ // tell a notice folded into the running turn from one that starts its own.
461
479
  // Kept in the sidecar so the lone GRANT `rmSync` stays where deletions of control
462
480
  // state are allowed. ---
463
481
  /**
@@ -489,6 +507,41 @@ export function setGrant(root, grant) {
489
507
  writeFileSync(grantPath(root), `${grant}\n`);
490
508
  }
491
509
  }
510
+ /**
511
+ * PROMPT: the id of the last prompt the turn hook saw, typed or not.
512
+ */
513
+ export function promptPath(root) {
514
+ return join(root, DIRNAME, 'PROMPT');
515
+ }
516
+ /**
517
+ * The id of the last prompt the turn hook saw, or null when none is recorded.
518
+ *
519
+ * A notice the harness folds into a running turn arrives under that turn's id,
520
+ * where one that starts a turn of its own arrives under a fresh one, so the
521
+ * hook compares a notice's id against this one to tell the two apart.
522
+ */
523
+ export function readPrompt(root) {
524
+ try {
525
+ const id = readFileSync(promptPath(root), 'utf8').trim();
526
+ return id === '' ? null : id;
527
+ }
528
+ catch {
529
+ return null;
530
+ }
531
+ }
532
+ /**
533
+ * Record the id of the prompt the hook just saw, or clear the record when the
534
+ * payload carried none, so a stale id can never make a later notice look as
535
+ * though it joined the running turn.
536
+ */
537
+ export function notePrompt(root, id) {
538
+ if (id === null) {
539
+ rmSync(promptPath(root), { force: true });
540
+ }
541
+ else {
542
+ writeFileSync(promptPath(root), `${id}\n`);
543
+ }
544
+ }
492
545
  /**
493
546
  * TICK: the per-build entry stamp: the TURN value recorded when work was
494
547
  * entered (`build <n>` for a step, `start` for the plan), cleared when
@@ -536,6 +589,143 @@ export function readTurn(root) {
536
589
  return null;
537
590
  }
538
591
  }
592
+ // --- The relay record: `.plumbbob/RELAY`, the ending the CLI printed last, so a
593
+ // Stop hook can check that the turn closed on it. The verb that prints an
594
+ // ending records it here: it knows what it printed, where a hook scraping
595
+ // stdout would have to guess which command ran. Flat like TURN, because the
596
+ // turn it is checked against is the session's, not a build's. A later ending
597
+ // overwrites an earlier one, so a turn is judged by the last thing it printed. ---
598
+ // The tiers of docs/presentation.md, which decide how a turn has to carry an
599
+ // ending: a decision ending closes the turn, an orientation ending appears in
600
+ // it, and a driver ending is recorded only so that the last ending wins.
601
+ const RELAY_TIERS = ['decision', 'orientation', 'driver'];
602
+ // Which ending printed: handoff's six forms first, then one per transition. It
603
+ // is what the check's reason names when the ending went missing.
604
+ const RELAY_RENDERS = [
605
+ 'card',
606
+ 'reminder',
607
+ 'plan',
608
+ 'boundary',
609
+ 'pointer',
610
+ 'driver',
611
+ 'checkpoint',
612
+ 'plan-commit',
613
+ 'park',
614
+ 'revert',
615
+ 'abandon',
616
+ 'spike-open',
617
+ 'spike-report',
618
+ 'spike-done',
619
+ 'use',
620
+ 'order',
621
+ 'finish',
622
+ 'start',
623
+ ];
624
+ /**
625
+ * RELAY: the ending the CLI printed last, at the flat sidecar root.
626
+ */
627
+ export function relayPath(root) {
628
+ return join(root, DIRNAME, 'RELAY');
629
+ }
630
+ /**
631
+ * Record an ending in RELAY, stamped with the current TURN: a header of
632
+ * `key value` lines, a blank line, then the text exactly as it printed.
633
+ *
634
+ * Written only while TURN holds a count, the same dormancy `stampTick` keeps: a
635
+ * host with no turn hook has no turn to check the ending against. Best-effort
636
+ * by contract, since the ending has already reached stdout and a failed record
637
+ * must not change it or the exit code.
638
+ */
639
+ export function writeRelay(root, relay) {
640
+ const turn = readTurn(root);
641
+ if (turn === null)
642
+ return;
643
+ // A hold the check already spent on this turn survives the ending the retry
644
+ // reprints, or a turn whose retry missed again would be held a second time.
645
+ const held = readRelay(root)?.held === turn ? { held: turn } : {};
646
+ try {
647
+ writeFileSync(relayPath(root), formatRelay({ ...relay, turn, ...held }));
648
+ }
649
+ catch {
650
+ // A read-only tree or a directory in the way: the ending already printed.
651
+ }
652
+ }
653
+ /**
654
+ * Drop the recorded ending, so the `Stop` check has nothing to judge until a
655
+ * verb prints another.
656
+ *
657
+ * The turn hook calls this when a background job's notice starts a turn of its
658
+ * own. The ending was judged when its own turn ended, and a notice leaves TURN
659
+ * where it was, so without this the notice's turn would read as the one that
660
+ * printed the ending and be held open for a card the human has already read.
661
+ */
662
+ export function clearRelay(root) {
663
+ rmSync(relayPath(root), { force: true });
664
+ }
665
+ /**
666
+ * Stamp the record with the turn the check held open over it, so the check
667
+ * holds at most once per turn. False when the stamp could not be written, and
668
+ * the check then holds nothing, since a hold it cannot count could repeat.
669
+ */
670
+ export function holdRelay(root, record) {
671
+ try {
672
+ writeFileSync(relayPath(root), formatRelay({ ...record, held: record.turn }));
673
+ return true;
674
+ }
675
+ catch {
676
+ return false;
677
+ }
678
+ }
679
+ /** A record as RELAY stores it: the header's `key value` lines, a blank line, then the text. */
680
+ function formatRelay(record) {
681
+ const header = [`turn ${record.turn}`, `tier ${record.tier}`, `render ${record.render}`];
682
+ if (record.command !== undefined)
683
+ header.push(`command ${record.command}`);
684
+ if (record.held !== undefined)
685
+ header.push(`held ${record.held}`);
686
+ return `${header.join('\n')}\n\n${record.text}`;
687
+ }
688
+ /**
689
+ * The recorded ending, or null when RELAY is absent or its header does not
690
+ * parse: a record the check cannot read is no record.
691
+ */
692
+ export function readRelay(root) {
693
+ let raw;
694
+ try {
695
+ raw = readFileSync(relayPath(root), 'utf8');
696
+ }
697
+ catch {
698
+ return null;
699
+ }
700
+ const split = raw.indexOf('\n\n');
701
+ if (split === -1)
702
+ return null;
703
+ const fields = new Map();
704
+ for (const line of raw.slice(0, split).split('\n')) {
705
+ const space = line.indexOf(' ');
706
+ if (space > 0)
707
+ fields.set(line.slice(0, space), line.slice(space + 1));
708
+ }
709
+ const turn = count(fields.get('turn'));
710
+ const tier = RELAY_TIERS.find((t) => t === fields.get('tier'));
711
+ const render = RELAY_RENDERS.find((r) => r === fields.get('render'));
712
+ if (turn === null || tier === undefined || render === undefined)
713
+ return null;
714
+ const command = fields.get('command');
715
+ const held = count(fields.get('held'));
716
+ return {
717
+ turn,
718
+ tier,
719
+ render,
720
+ ...(command === undefined ? {} : { command }),
721
+ ...(held === null ? {} : { held }),
722
+ text: raw.slice(split + 2),
723
+ };
724
+ }
725
+ /** A header field read as a bare count, or null when it is absent or anything else. */
726
+ function count(field) {
727
+ return field !== undefined && /^\d+$/.test(field) ? Number(field) : null;
728
+ }
539
729
  /**
540
730
  * Walk up from `cwd` to the nearest ancestor with an active session (a
541
731
  * `.plumbbob/STATE` file), or null when there is none.
@@ -599,17 +789,26 @@ export function excludeControl(root) {
599
789
  // never writes, and never commits.
600
790
  `${DIRNAME}/TURN`,
601
791
  `${DIRNAME}/GRANT`,
792
+ // The id of the last prompt the turn hook saw, which tells a notice folded
793
+ // into the running turn from one that starts its own.
794
+ `${DIRNAME}/PROMPT`,
602
795
  // 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.
796
+ // overwrites before every pause. checkpoint records it in the build-log's
797
+ // Log and then clears it, so its archive is the tracked ledger, never this
798
+ // path; excluding it keeps stageAll from ever sweeping it into a step commit.
606
799
  `${DIRNAME}/detail.md`,
800
+ // The ending the CLI printed last, for the Stop check: per-worktree
801
+ // control, rewritten by every ending, and never a step commit's to carry.
802
+ `${DIRNAME}/RELAY`,
607
803
  `${DIRNAME}/builds/*/STEP`,
608
804
  `${DIRNAME}/builds/*/SEAM`,
609
805
  `${DIRNAME}/builds/*/SPIKE`,
610
806
  // The entry stamp is in-flight control like STEP/SEAM: checkpoint's
611
807
  // stageAll must never sweep it into a step commit.
612
808
  `${DIRNAME}/builds/*/TICK`,
809
+ // The pause stamp is in-flight control too: what handoff last showed, so
810
+ // a repeat prints the reminder. It must never ride a step commit.
811
+ `${DIRNAME}/builds/*/PAUSE`,
613
812
  // The agent-run handoff ledger is step-scoped in-flight state, not a
614
813
  // tracked artifact: it must never ride a step commit into the PR.
615
814
  `${DIRNAME}/builds/*/handoff.json`,
@@ -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
+ }
@@ -1,11 +1,11 @@
1
1
  // `plumbbob abandon`: the third exit from an in-flight step. Checkpoint lands
2
2
  // the step and revert destroys the work; abandon drops the attempt but keeps the
3
3
  // working-tree diff exactly where it is. Its only writes are the in-flight
4
- // control markers it clears (STEP, SEAM, TICK, and the step-scoped handoff
5
- // ledger), a build-log line, and the stats receipt: it touches neither the
6
- // working tree nor git nor the intent checkbox, so the step stays planned and
7
- // re-buildable with its diff still in the tree for the human to keep, rework, or
8
- // commit by hand.
4
+ // control markers it clears (STEP, SEAM, TICK, the PAUSE stamp, and the
5
+ // step-scoped handoff ledger), a build-log line, and the stats receipt: it
6
+ // touches neither the working tree nor git nor the intent checkbox, so the step
7
+ // stays planned and re-buildable with its diff still in the tree for the human
8
+ // to keep, rework, or commit by hand.
9
9
  //
10
10
  // A step exit is a boundary crossing, so abandon honors the same approval latch
11
11
  // as checkpoint. That is not decoration: abandon clears the entry stamp the
@@ -15,11 +15,11 @@
15
15
  // control-state clears; abandon itself never calls the raw fs deleters.
16
16
  import { readFileSync, writeFileSync } from 'node:fs';
17
17
  import { findRepoRoot } from "../lib/git.js";
18
- import { bumpStepStat, buildLogPath, clearHandoff, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
18
+ import { bumpStepStat, buildLogPath, clearHandoff, clearPause, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
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";
22
+ import { ending, notice, printEnding, transition } from "../lib/notice.js";
23
23
  import { driverPointer } from "./handoff.js";
24
24
  /**
25
25
  * Drop the in-flight step while keeping its work: latch, clear the markers,
@@ -55,18 +55,23 @@ export function abandon(cwd, args) {
55
55
  clearSeam(root, slug);
56
56
  clearTick(root, slug);
57
57
  clearHandoff(root, slug);
58
+ clearPause(root, slug);
58
59
  bumpStepStat(root, slug, step, 'abandons');
59
60
  logAbandon(root, slug, step);
60
61
  // A step exit: with the step back at `[ ]` and its markers gone, the pointer
61
62
  // 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'],
63
+ printEnding(root, {
64
+ tier: 'driver',
65
+ render: 'abandon',
66
+ text: ending({
67
+ lead: transition({
68
+ label: 'Abandoned',
69
+ fact: `Step ${step}`,
70
+ detail: ['work kept in the tree', 'the step stays planned'],
71
+ }),
72
+ pointer: driverPointer(root, slug),
67
73
  }),
68
- pointer: driverPointer(root, slug),
69
- }));
74
+ });
70
75
  return 0;
71
76
  }
72
77
  /**
@@ -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 });
@@ -12,7 +12,7 @@
12
12
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { relative } from 'node:path';
14
14
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
15
- import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
15
+ import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearPause, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
16
16
  import { runCheck } from "../lib/check.js";
17
17
  import { readCommitBody } from "../lib/commitbody.js";
18
18
  import { checkLatch } from "../lib/latch.js";
@@ -21,7 +21,7 @@ import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
21
21
  import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
22
22
  import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
23
23
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
24
- import { advisory, ending, notice, transition } from "../lib/notice.js";
24
+ import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
25
25
  import { boundaryEnding, planRecord, stepRecord } from "./handoff.js";
26
26
  /**
27
27
  * Land a step: latch, check gate, commit, record, return to the boundary.
@@ -116,17 +116,23 @@ export async function checkpoint(cwd, args) {
116
116
  rmSync(stepPath(root), { force: true });
117
117
  clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
118
118
  clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
119
+ clearPause(root); // the pause it stamped is over; the next step's pause renders whole.
119
120
  // The whole ending, one block: the lead line, the Verdict folded over what
120
121
  // just landed, the advisories this checkpoint alone can state, and the
121
122
  // pointer. Read after the flip and the ledger append, so the fold and the
122
123
  // pointer both see the boundary the human is standing at.
123
124
  const landed = boundaryEnding(root, activeBuild(root), step);
124
- process.stdout.write(ending({
125
- lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
126
- verdict: landed.verdict,
127
- advisories: [drift, flipNotice],
128
- pointer: landed.pointer,
129
- }));
125
+ printEnding(root, {
126
+ tier: 'orientation',
127
+ render: 'checkpoint',
128
+ text: ending({
129
+ lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
130
+ verdict: landed.verdict,
131
+ advisories: [drift, flipNotice],
132
+ stop: landed.stop,
133
+ pointer: landed.pointer,
134
+ }),
135
+ });
130
136
  return 0;
131
137
  }
132
138
  /**
@@ -172,19 +178,23 @@ function checkpointPlan(root, args) {
172
178
  clearTick(root);
173
179
  // No Verdict: the plan commit measures nothing. The pointer aims at the step
174
180
  // the build starts on, which is what `handoff` renders from a null step.
175
- process.stdout.write(ending({
176
- lead: transition({ label: 'Plan', fact: 'committed', detail: leadDetail(sha, pointer) }),
177
- advisories: staged
178
- ? []
179
- : [
180
- advisory({
181
- fact: 'the plan rides the commit message',
182
- detail: ['record-only', '.plumbbob/ is gitignored, so the files stay untracked'],
183
- remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
184
- }),
185
- ],
186
- pointer: boundaryEnding(root, activeBuild(root), null).pointer,
187
- }));
181
+ printEnding(root, {
182
+ tier: 'orientation',
183
+ render: 'plan-commit',
184
+ text: ending({
185
+ lead: transition({ label: 'Plan', fact: 'committed', detail: leadDetail(sha, pointer) }),
186
+ advisories: staged
187
+ ? []
188
+ : [
189
+ advisory({
190
+ fact: 'the plan rides the commit message',
191
+ detail: ['record-only', '.plumbbob/ is gitignored, so the files stay untracked'],
192
+ remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
193
+ }),
194
+ ],
195
+ pointer: boundaryEnding(root, activeBuild(root), null).pointer,
196
+ }),
197
+ });
188
198
  return 0;
189
199
  }
190
200
  /**