plumbbob 0.13.0 → 0.13.2

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.
@@ -18,6 +18,7 @@
18
18
  // human-owned (check/auto): the tool only ever reads it, never rewrites it. The
19
19
  // phase the dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored: BUILD ⇔
20
20
  // a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
21
+ import { createHash } from 'node:crypto';
21
22
  import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
22
23
  import { dirname, join } from 'node:path';
23
24
  import { gitPath } from "./git.js";
@@ -228,6 +229,61 @@ export function pausePath(root, slug) {
228
229
  export function clearPause(root, slug) {
229
230
  rmSync(pausePath(root, slug), { force: true });
230
231
  }
232
+ /**
233
+ * STOP: the stamp of the stop card `handoff --stop` last rendered whole, the
234
+ * range it reviews, the TURN it printed in, a hash of what the card showed,
235
+ * and a digest of the detail file it printed from. While a later render
236
+ * matches the range and the hash, handoff prints the one-line reminder;
237
+ * `plumbbob harvest` records the stop only from the detail the card showed,
238
+ * and only once a human turn has read it.
239
+ */
240
+ export function stopPath(root, slug) {
241
+ return join(artifactDir(root, slug), 'STOP');
242
+ }
243
+ /**
244
+ * The stamp of the stop card last rendered, or null when STOP is absent or
245
+ * does not parse.
246
+ */
247
+ export function readStop(root, slug) {
248
+ try {
249
+ const m = /^(\d+) (\d+|-) (\S+)(?: (\S+))?$/.exec(readFileSync(stopPath(root, slug), 'utf8').trim());
250
+ if (m === null)
251
+ return null;
252
+ return { range: Number(m[1]), turn: m[2] === '-' ? null : Number(m[2]), hash: m[3] ?? '', detail: m[4] ?? null };
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ }
258
+ /**
259
+ * Record the stamp of the stop card just printed, with the digest of the
260
+ * detail file as it stands, which is the file the card was rendered from.
261
+ * Best-effort: a failed write costs one more full card on a later turn, never
262
+ * this turn's output or its exit code.
263
+ */
264
+ export function writeStop(root, slug, stamp) {
265
+ try {
266
+ const detail = detailDigest(readFileSync(detailPath(root), 'utf8'));
267
+ writeFileSync(stopPath(root, slug), `${stamp.range} ${stamp.turn ?? '-'} ${stamp.hash} ${detail}\n`);
268
+ }
269
+ catch {
270
+ // A read-only tree or a vanished build folder: the card still renders.
271
+ }
272
+ }
273
+ /**
274
+ * Remove the STOP stamp: the stop's record ends the card it recorded. Absent
275
+ * is a no-op.
276
+ */
277
+ export function clearStop(root, slug) {
278
+ rmSync(stopPath(root, slug), { force: true });
279
+ }
280
+ /**
281
+ * The digest a STOP stamp keeps of a detail file, so the record can tell the
282
+ * detail a card showed from one written after it.
283
+ */
284
+ export function detailDigest(detail) {
285
+ return createHash('sha256').update(detail).digest('hex');
286
+ }
231
287
  /**
232
288
  * The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
233
289
  * commits, one line each.
@@ -473,6 +529,9 @@ export function clearSpike(root, slug) {
473
529
  // `plumbbob turn` (the UserPromptSubmit hook) ticks TURN once per human prompt
474
530
  // and rewrites GRANT from the literal prompt; the checkpoint latch reads them to
475
531
  // refuse a land until a human turn has intervened since the step was entered.
532
+ // A background job's notice reaches the same hook and ticks neither; PROMPT,
533
+ // beside them, records the id of the last prompt the hook saw, so the hook can
534
+ // tell a notice folded into the running turn from one that starts its own.
476
535
  // Kept in the sidecar so the lone GRANT `rmSync` stays where deletions of control
477
536
  // state are allowed. ---
478
537
  /**
@@ -489,7 +548,7 @@ export function grantPath(root) {
489
548
  }
490
549
  /**
491
550
  * Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
492
- * (`auto` | `range M`), null clears it.
551
+ * (`auto` | `steps <list>`), null clears it.
493
552
  *
494
553
  * The turn hook calls this on every human prompt (minted on a match, cleared
495
554
  * otherwise) so a grant lives exactly one turn by construction. Pairing the
@@ -504,6 +563,75 @@ export function setGrant(root, grant) {
504
563
  writeFileSync(grantPath(root), `${grant}\n`);
505
564
  }
506
565
  }
566
+ /**
567
+ * RUN: the range a `/plumbbob:run` the human typed is landing, written beside
568
+ * GRANT by the turn hook so the checkpoint that completes the range knows a
569
+ * run landed it. The latch never reads it.
570
+ */
571
+ export function runPath(root) {
572
+ return join(root, DIRNAME, 'RUN');
573
+ }
574
+ /**
575
+ * The range number the RUN mark names, or null when the mark is absent or
576
+ * holds anything but a positive number.
577
+ */
578
+ export function readRun(root) {
579
+ try {
580
+ const n = Number(readFileSync(runPath(root), 'utf8').trim());
581
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
582
+ }
583
+ catch {
584
+ return null;
585
+ }
586
+ }
587
+ /**
588
+ * Rewrite (or clear) the RUN mark: a range number marks that range's run,
589
+ * null clears it. The turn hook calls this on every typed prompt, beside
590
+ * `setGrant`, and the delete lives here with the grant's.
591
+ */
592
+ export function setRun(root, item) {
593
+ if (item === null) {
594
+ rmSync(runPath(root), { force: true });
595
+ }
596
+ else {
597
+ writeFileSync(runPath(root), `${item}\n`);
598
+ }
599
+ }
600
+ /**
601
+ * PROMPT: the id of the last prompt the turn hook saw, typed or not.
602
+ */
603
+ export function promptPath(root) {
604
+ return join(root, DIRNAME, 'PROMPT');
605
+ }
606
+ /**
607
+ * The id of the last prompt the turn hook saw, or null when none is recorded.
608
+ *
609
+ * A notice the harness folds into a running turn arrives under that turn's id,
610
+ * where one that starts a turn of its own arrives under a fresh one, so the
611
+ * hook compares a notice's id against this one to tell the two apart.
612
+ */
613
+ export function readPrompt(root) {
614
+ try {
615
+ const id = readFileSync(promptPath(root), 'utf8').trim();
616
+ return id === '' ? null : id;
617
+ }
618
+ catch {
619
+ return null;
620
+ }
621
+ }
622
+ /**
623
+ * Record the id of the prompt the hook just saw, or clear the record when the
624
+ * payload carried none, so a stale id can never make a later notice look as
625
+ * though it joined the running turn.
626
+ */
627
+ export function notePrompt(root, id) {
628
+ if (id === null) {
629
+ rmSync(promptPath(root), { force: true });
630
+ }
631
+ else {
632
+ writeFileSync(promptPath(root), `${id}\n`);
633
+ }
634
+ }
507
635
  /**
508
636
  * TICK: the per-build entry stamp: the TURN value recorded when work was
509
637
  * entered (`build <n>` for a step, `start` for the plan), cleared when
@@ -561,11 +689,13 @@ export function readTurn(root) {
561
689
  // ending: a decision ending closes the turn, an orientation ending appears in
562
690
  // it, and a driver ending is recorded only so that the last ending wins.
563
691
  const RELAY_TIERS = ['decision', 'orientation', 'driver'];
564
- // Which ending printed: handoff's six forms first, then one per transition. It
565
- // is what the check's reason names when the ending went missing.
692
+ // Which ending printed: handoff's eight forms first, then one per transition.
693
+ // It is what the check's reason names when the ending went missing.
566
694
  const RELAY_RENDERS = [
567
695
  'card',
568
696
  'reminder',
697
+ 'stop',
698
+ 'stop-reminder',
569
699
  'plan',
570
700
  'boundary',
571
701
  'pointer',
@@ -580,6 +710,7 @@ const RELAY_RENDERS = [
580
710
  'spike-done',
581
711
  'use',
582
712
  'order',
713
+ 'harvest',
583
714
  'finish',
584
715
  'start',
585
716
  ];
@@ -612,6 +743,18 @@ export function writeRelay(root, relay) {
612
743
  // A read-only tree or a directory in the way: the ending already printed.
613
744
  }
614
745
  }
746
+ /**
747
+ * Drop the recorded ending, so the `Stop` check has nothing to judge until a
748
+ * verb prints another.
749
+ *
750
+ * The turn hook calls this when a background job's notice starts a turn of its
751
+ * own. The ending was judged when its own turn ended, and a notice leaves TURN
752
+ * where it was, so without this the notice's turn would read as the one that
753
+ * printed the ending and be held open for a card the human has already read.
754
+ */
755
+ export function clearRelay(root) {
756
+ rmSync(relayPath(root), { force: true });
757
+ }
615
758
  /**
616
759
  * Stamp the record with the turn the check held open over it, so the check
617
760
  * holds at most once per turn. False when the stamp could not be written, and
@@ -631,10 +774,18 @@ function formatRelay(record) {
631
774
  const header = [`turn ${record.turn}`, `tier ${record.tier}`, `render ${record.render}`];
632
775
  if (record.command !== undefined)
633
776
  header.push(`command ${record.command}`);
777
+ if (record.owes !== undefined) {
778
+ header.push(`owes range ${record.owes.range} of ${record.owes.count} after step ${record.owes.step}`);
779
+ }
634
780
  if (record.held !== undefined)
635
781
  header.push(`held ${record.held}`);
636
782
  return `${header.join('\n')}\n\n${record.text}`;
637
783
  }
784
+ /** The `owes` header field read back, or null when it is absent or does not parse. */
785
+ function owedOf(field) {
786
+ const m = /^range (\d+) of (\d+) after step (\d+)$/.exec(field ?? '');
787
+ return m === null ? null : { range: Number(m[1]), count: Number(m[2]), step: Number(m[3]) };
788
+ }
638
789
  /**
639
790
  * The recorded ending, or null when RELAY is absent or its header does not
640
791
  * parse: a record the check cannot read is no record.
@@ -662,12 +813,14 @@ export function readRelay(root) {
662
813
  if (turn === null || tier === undefined || render === undefined)
663
814
  return null;
664
815
  const command = fields.get('command');
816
+ const owes = owedOf(fields.get('owes'));
665
817
  const held = count(fields.get('held'));
666
818
  return {
667
819
  turn,
668
820
  tier,
669
821
  render,
670
822
  ...(command === undefined ? {} : { command }),
823
+ ...(owes === null ? {} : { owes }),
671
824
  ...(held === null ? {} : { held }),
672
825
  text: raw.slice(split + 2),
673
826
  };
@@ -739,6 +892,11 @@ export function excludeControl(root) {
739
892
  // never writes, and never commits.
740
893
  `${DIRNAME}/TURN`,
741
894
  `${DIRNAME}/GRANT`,
895
+ // The range a typed run is landing, written beside the grant.
896
+ `${DIRNAME}/RUN`,
897
+ // The id of the last prompt the turn hook saw, which tells a notice folded
898
+ // into the running turn from one that starts its own.
899
+ `${DIRNAME}/PROMPT`,
742
900
  // The in-flight step's detail file: flat per-session control the model
743
901
  // overwrites before every pause. checkpoint records it in the build-log's
744
902
  // Log and then clears it, so its archive is the tracked ledger, never this
@@ -756,6 +914,9 @@ export function excludeControl(root) {
756
914
  // The pause stamp is in-flight control too: what handoff last showed, so
757
915
  // a repeat prints the reminder. It must never ride a step commit.
758
916
  `${DIRNAME}/builds/*/PAUSE`,
917
+ // The stop stamp likewise: what the stop card last showed, and the turn it
918
+ // printed in, for the reminder and the record's latch.
919
+ `${DIRNAME}/builds/*/STOP`,
759
920
  // The agent-run handoff ledger is step-scoped in-flight state, not a
760
921
  // tracked artifact: it must never ride a step commit into the PR.
761
922
  `${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
+ }
@@ -8,18 +8,22 @@ 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, stopFollowUp } 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";
16
+ import { waitingCard } from "./handoff.js";
15
17
  /**
16
18
  * Enter step n (or the next undone step) and write its SEAM/STEP markers.
17
19
  *
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.
20
+ * Refuses a missing session, a malformed step argument, a range such as `N-M`
21
+ * or `20,5-9` (a `/plumbbob:build` skill feature, not a CLI one), a stop card
22
+ * still waiting for its reply, and a seam that fails to parse.
20
23
  * On entry it stamps the turn ledger and the step's start time, re-renders the
21
24
  * build-log's Current step line, and names the step when it is the last one a
22
- * self-approval grant reaches.
25
+ * self-approval grant reaches, or, under `--auto`, a call the plan's ranges
26
+ * reserve for the human.
23
27
  */
24
28
  export function build(cwd, args) {
25
29
  const root = findRepoRoot(cwd);
@@ -29,15 +33,15 @@ export function build(cwd, args) {
29
33
  }
30
34
  const { build: slug, rest } = resolveBuild(root, args);
31
35
  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
36
+ // A step range like `1-3` or `20,5-9` is a `/plumbbob:build` skill affordance
37
+ // (auto-approve each step it names, then stop), not a CLI capability: the CLI
34
38
  // 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)) {
39
+ // generic usage, and point at the step the range starts at.
40
+ if (raw !== undefined && /^\d[\d,-]*$/.test(raw) && /[,-]/.test(raw)) {
37
41
  process.stderr.write(notice({
38
42
  fact: 'build takes one step number',
39
43
  detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through its top and then stops`],
40
- remedy: `plumbbob build ${raw.split('-')[0]}`,
44
+ remedy: `plumbbob build ${rangeEntry(intentPath(root, slug), rest.join(' '), raw)}`,
41
45
  }));
42
46
  return 1;
43
47
  }
@@ -48,6 +52,15 @@ export function build(cwd, args) {
48
52
  return 1;
49
53
  }
50
54
  const intent = readFileSync(intentPath(root, slug), 'utf8');
55
+ // A stop card waiting for its reply holds every step, bare or named: the
56
+ // step's pause would write over the range's review in the detail file before
57
+ // the reply recorded it. Nothing is written, and on the plan's last range the
58
+ // card outranks "no undone step".
59
+ const waiting = waitingNotice(root, slug, intent);
60
+ if (waiting.length > 0) {
61
+ process.stderr.write(waiting);
62
+ return 1;
63
+ }
51
64
  // In build order: the sequence `## Build order` declares when a refined plan
52
65
  // has one, else the numbering, so this pick and the dashboard's agree.
53
66
  const steps = parseOrderedSteps(intent);
@@ -100,11 +113,15 @@ export function build(cwd, args) {
100
113
  : skipped > 0
101
114
  ? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
102
115
  : [];
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.
116
+ // Two lines (three on the last step a grant reaches, or on a call the plan
117
+ // reserves), one colon each, then the seam as a plain readout beneath the
118
+ // notice that frames it: the paths are a list, and a list is not a one-liner.
119
+ // A reserved call outranks the last-step line, since the step stops at its
120
+ // pause rather than landing.
121
+ const grant = readGrant(root);
122
+ const reserved = grant?.kind === 'auto' ? reservedCallNotice(intent, steps, step) : '';
106
123
  process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
107
- lastGrantedNotice(readGrant(root), steps, step) +
124
+ (reserved.length > 0 ? reserved : lastGrantedNotice(grant, steps, step)) +
108
125
  notice({
109
126
  fact: 'the seam is orientation, not a lock',
110
127
  detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
@@ -112,6 +129,70 @@ export function build(cwd, args) {
112
129
  `${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
113
130
  return 0;
114
131
  }
132
+ /**
133
+ * The refusal while a stop card waits for its reply, or '' when none waits:
134
+ * the card, then the reply that records it. `looks good` always does, and the
135
+ * remedy adds the move the card's second row names, read through the same
136
+ * `stopFollowUp`: the next range's run, the finish on the plan's last range,
137
+ * or nothing more when the next run would refuse. The remedy spends a
138
+ * semicolon, since the refusal register keeps its one colon for the prefix.
139
+ */
140
+ function waitingNotice(root, slug, intent) {
141
+ const card = waitingCard(root, slug, intent);
142
+ if (card === null) {
143
+ return '';
144
+ }
145
+ const follow = stopFollowUp(intent);
146
+ const also = follow === null
147
+ ? ''
148
+ : follow === 'finish'
149
+ ? ', and /plumbbob:finish records it, then writes the report'
150
+ : `, and /plumbbob:run records it, then runs range ${follow.range.item} of ${follow.count}`;
151
+ return notice({
152
+ fact: 'no step entered',
153
+ detail: [`range ${card.range} of ${card.count}'s stop card is waiting for its reply`],
154
+ remedy: `reply to the card; looks good records it${also}`,
155
+ });
156
+ }
157
+ /**
158
+ * The step a range typed at the CLI starts at: its first undone step in build
159
+ * order when the plan reads, else its first number written.
160
+ *
161
+ * Best-effort, since it only fills the refusal's remedy: a plan that cannot be
162
+ * read, or a range that does not parse, still points at a number the human typed.
163
+ */
164
+ function rangeEntry(intent, args, raw) {
165
+ const list = readBuildArgs(args).steps;
166
+ try {
167
+ const start = list === null ? null : rangeStart(parseOrderedSteps(readFileSync(intent, 'utf8')), list);
168
+ if (start !== null)
169
+ return String(start);
170
+ }
171
+ catch {
172
+ // No plan to order by: fall back to the number typed first.
173
+ }
174
+ return /\d+/.exec(raw)?.[0] ?? '1';
175
+ }
176
+ /**
177
+ * The line saying a step ends at its pause under `--auto`, because the plan's
178
+ * build ranges name it as a bare range: a call the plan reserves for the
179
+ * human. '' for any other step.
180
+ *
181
+ * The halt is this line's and the skill's, never the latch's: the ranges are
182
+ * advice, so `--auto` still grants the checkpoint, and saying so as the step
183
+ * is entered is what puts the stop in front of the model before it lands one.
184
+ */
185
+ function reservedCallNotice(intent, steps, step) {
186
+ const { count, holding } = rangeStanding(parseBuildRanges(intent), steps, step);
187
+ if (holding === null || !holding.bare) {
188
+ return '';
189
+ }
190
+ return notice({
191
+ fact: `step ${step} ends at its pause, where the plan reserves the call for the human`,
192
+ detail: [`range ${holding.item} of ${count}`],
193
+ remedy: 'hold it there for their approval, --auto or not',
194
+ });
195
+ }
115
196
  /**
116
197
  * The line naming the last step a self-approval grant reaches this turn, or ''
117
198
  * for any other step and when no grant is live.
@@ -123,13 +204,26 @@ export function build(cwd, args) {
123
204
  * reaches its top, or the last undone step under its top when the plan ends
124
205
  * first, or when the build order puts a number past the top ahead of the rest
125
206
  * (a range names plan numbers, and the skill halts before one past its top);
126
- * `--auto` reaches the plan's last undone step.
207
+ * `--auto` reaches the plan's last undone step. A range that lists its steps
208
+ * walks them in build order, so its top is the last undone step it names, and
209
+ * it reaches no further than a step it does not name.
127
210
  */
128
211
  function lastGrantedNotice(grant, steps, step) {
129
212
  if (grant === null) {
130
213
  return '';
131
214
  }
132
215
  const remedy = 'land it on green, then stop at the boundary';
216
+ if (grant.kind === 'steps') {
217
+ if (!hasStep(grant.steps, step))
218
+ return '';
219
+ const rest = steps.slice(steps.findIndex((s) => s.n === step) + 1).filter((s) => !s.done && s.n !== step);
220
+ const after = rest[0];
221
+ if (after !== undefined && hasStep(grant.steps, after.n))
222
+ return '';
223
+ return rest.some((s) => hasStep(grant.steps, s.n))
224
+ ? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
225
+ : notice({ fact: `step ${step} is the top of the range you granted`, remedy });
226
+ }
133
227
  if (grant.kind === 'range') {
134
228
  if (step === grant.ceiling) {
135
229
  return notice({ fact: `step ${step} is the top of the range you granted`, remedy });