plumbbob 0.12.1 → 0.13.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.
@@ -17,7 +17,7 @@ import { readCommitBody } from "../lib/commitbody.js";
17
17
  import { buildScope, checkpointsPath, clearTick, hasSession, intentPath, readStats, refreshExcludes, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
18
18
  import { conventionalSubject, withMarker } from "../lib/commitmsg.js";
19
19
  import { parseBuildScope } from "../lib/intent.js";
20
- import { advisory, ending, notice, transition } from "../lib/notice.js";
20
+ import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
21
21
  // The pointer finish leaves the turn on. Every other one is `handoff`'s, but a
22
22
  // finished session has no state left to render from, and past a closed build
23
23
  // the only move is framing the next goal.
@@ -84,23 +84,27 @@ export function finish(cwd, args = []) {
84
84
  // the turn's last text. finish renders that pointer itself: it just cleared
85
85
  // the session `handoff` would read one from.
86
86
  const where = slug === null ? '.plumbbob/' : `.plumbbob/builds/${slug}/`;
87
- process.stdout.write(ending({
88
- lead: transition({
89
- label: 'Session',
90
- fact: 'finished',
91
- detail: [sha.slice(0, 9), `${where} rides your branch into the PR`],
87
+ printEnding(root, {
88
+ tier: 'orientation',
89
+ render: 'finish',
90
+ text: ending({
91
+ lead: transition({
92
+ label: 'Session',
93
+ fact: 'finished',
94
+ detail: [sha.slice(0, 9), `${where} rides your branch into the PR`],
95
+ }),
96
+ advisories: reported
97
+ ? []
98
+ : [
99
+ advisory({
100
+ fact: 'no report.md found',
101
+ detail: ['finished without one', 'no gate here by design'],
102
+ remedy: '/plumbbob:finish normally writes the report first',
103
+ }),
104
+ ],
105
+ pointer: NEXT_UP,
92
106
  }),
93
- advisories: reported
94
- ? []
95
- : [
96
- advisory({
97
- fact: 'no report.md found',
98
- detail: ['finished without one', 'no gate here by design'],
99
- remedy: '/plumbbob:finish normally writes the report first',
100
- }),
101
- ],
102
- pointer: NEXT_UP,
103
- }));
107
+ });
104
108
  return 0;
105
109
  }
106
110
  /**
@@ -1,13 +1,21 @@
1
1
  // `plumbbob handoff`: emit the CLI-owned ending of a turn as one contiguous
2
- // block (docs/presentation.md). Read-only, no state change. Every part outside
2
+ // block (docs/presentation.md). Changes no state but three: at a pause it
3
+ // leaves the detail file with a blank line around each heading, at a step pause
4
+ // it stamps what the card showed in `PAUSE`, and every render it prints is
5
+ // recorded in `RELAY` for the check at the turn's end. Every part outside
3
6
  // the readout fence is a bold label, a colon, and text that wraps, one blank
4
7
  // line between blocks and no fence but the readout's own. It derives the moment
5
- // from the session: a step in flight ⇒ the decision-tier ending (the labeled
6
- // Readout and its fence, the inline diff when the change is 20 lines or fewer,
7
- // the Verdict, Next Up, Your Call, and the Recommendation last); a landed step
8
+ // from the session: a step in flight ⇒ the decision-tier ending (the Now line
9
+ // naming that step, the labeled Readout and its fence, the inline diff when
10
+ // the change is 20 lines or fewer, the Verdict, Next Up, Your Call, and the
11
+ // Recommendation last); a landed step
8
12
  // with none in flight ⇒ the orientation-tier ending (Verdict and Next Up); a
9
13
  // fresh session with nothing yet measured ⇒ the forward pointer alone.
10
14
  //
15
+ // A step pause renders its card once (D87 (pause-once)). While the stamp still
16
+ // matches, a later render is the one-line reminder, the driver pointer back at
17
+ // the step; the card comes back when the pause changes or on `--full`.
18
+ //
11
19
  // The whole decision turn is rendered here, its opening block included: the
12
20
  // Summary lead and the numbered highlights come out of `.plumbbob/detail.md`
13
21
  // as the markdown the model wrote, with the `(details: …)` bracket appended so
@@ -29,22 +37,18 @@
29
37
  // the step names closing the spike, a step exit (a revert, an abandon, a spike
30
38
  // closed at the boundary) finds nothing in flight and points forward, with no
31
39
  // Verdict either way, since nothing landed.
32
- import { readFileSync } from 'node:fs';
40
+ import { createHash } from 'node:crypto';
41
+ import { readFileSync, writeFileSync } from 'node:fs';
33
42
  import { join, relative } from 'node:path';
34
- import { commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
35
- import { blocks } from "../lib/notice.js";
43
+ import { PLAN_COMMIT_MARKER, commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
44
+ import { blocks, printEnding } from "../lib/notice.js";
36
45
  import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
37
- import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
38
- import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, parkedPhrase, parseBuildOrder, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.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, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
39
48
  // The inline-diff threshold (docs/presentation.md): at 20 changed lines or
40
49
  // fewer the patch rides the turn; at 21 it stays in the working tree behind
41
50
  // the counted diff row.
42
51
  const INLINE_DIFF_MAX = 20;
43
- // plumbbob's own plan commits carry this marker on a line of their body. A
44
- // `chore(plan)` harvest lands between nearly every step, so counting them as
45
- // out-of-band would trip the advisory rung on routine housekeeping and stop
46
- // meaning anything within three steps.
47
- const PLAN_COMMIT_MARKER = '^plumbbob plan$';
48
52
  // The seam rule, and the one tier that still has a seam to mark: at the plan
49
53
  // pause the model presents the framed plan above the relay, so the rule keeps
50
54
  // a label from reading as the tail of what precedes it, and a run of blank
@@ -58,11 +62,14 @@ const SEAM_RULE = '\n---';
58
62
  *
59
63
  * Requires an active session (the STATE sentinel under `.plumbbob/`). The step
60
64
  * tiers are derived, not passed: a step in flight yields the full
61
- * decision-tier ending (the Readout and its fence, the inline diff when small
62
- * enough, the Verdict, Next Up, Your Call, and the Recommendation), and so does
65
+ * decision-tier ending (the Now line, the Readout and its fence, the inline
66
+ * diff when small enough, the Verdict, Next Up, Your Call, and the
67
+ * Recommendation), and so does
63
68
  * a named step that has not landed when nothing is in flight (a hand-built diff
64
- * under verify), a landed step yields the orientation-tier ending (Verdict and
65
- * Next Up, no Your Call),
69
+ * under verify). That card renders once: a later render of the same pause, with
70
+ * nothing in it changed, is the one-line reminder, and `--full` prints the card
71
+ * whatever the stamp says. A landed step yields the orientation-tier ending
72
+ * (Verdict and Next Up, no Your Call),
66
73
  * and a fresh session with nothing to report yields only
67
74
  * the forward pointer. The two endings no session state can distinguish are
68
75
  * named by a flag: `--plan` is the plan pause (a decision turn about the plan,
@@ -80,13 +87,16 @@ export function handoff(cwd, args = []) {
80
87
  process.stderr.write('plumbbob: handoff: --plan and --driver name different tiers; pass one.\n');
81
88
  return 1;
82
89
  }
90
+ // A build named by flag rides any command that reprints this ending, since
91
+ // the cursor may point elsewhere.
92
+ const named = args.includes('--build') ? slug : null;
83
93
  const intent = readOr(intentPath(root, slug));
84
94
  // In build order, so the pointer names the step a bare `plumbbob build`
85
95
  // would enter; the build-log feeds the park count beneath it.
86
96
  const steps = parseOrderedSteps(intent);
87
97
  const buildLog = readOr(buildLogPath(root, slug));
88
98
  const inFlight = readMarker(stepPath(root, slug));
89
- const detail = readOr(detailPath(root));
99
+ const rawDetail = readOr(detailPath(root));
90
100
  // Where a pointer sends the reader to read the step itself: the intent file
91
101
  // as the human would type it, relative to the repo root.
92
102
  const where = relative(root, intentPath(root, slug));
@@ -95,12 +105,14 @@ export function handoff(cwd, args = []) {
95
105
  // renders; the pointer and the moves both aim at the first undone step. A
96
106
  // decision turn still ends on the model's recommendation when it wrote one.
97
107
  const first = steps.find((s) => !s.done);
108
+ const detail = settleDetail(root, rawDetail);
98
109
  const plan = [
99
110
  SEAM_RULE,
100
111
  nextUpBlock(first, steps.length, where, nextUpNote(intent, buildLog, null)),
101
112
  planCallBlock(first),
102
113
  ];
103
- return emit(withRecommendation(plan, parseRecommendation(detail)));
114
+ const command = reprintCommand('--plan', undefined, named);
115
+ return emit(root, { tier: 'decision', render: 'plan', command }, withRecommendation(plan, parseRecommendation(detail)));
104
116
  }
105
117
  const explicit = rest.find((a) => /^\d+$/.test(a));
106
118
  const lastDone = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
@@ -120,11 +132,13 @@ export function handoff(cwd, args = []) {
120
132
  // (a step exit: a revert, an abandon, a spike closed at the boundary) the
121
133
  // ordinary forward pointer stands, and no Verdict rides above it: nothing
122
134
  // landed to measure.
123
- return emit([driverPointer(root, slug)]);
135
+ return emit(root, { tier: 'driver', render: 'driver' }, [driverPointer(root, slug)]);
124
136
  }
125
137
  if (current === null) {
126
138
  // Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
127
- return emit([nextUpBlock(nextUp, steps.length, where, nextUpNote(intent, buildLog, current))]);
139
+ return emit(root, { tier: 'orientation', render: 'pointer' }, [
140
+ nextUpBlock(nextUp, steps.length, where, nextUpNote(intent, buildLog, current)),
141
+ ]);
128
142
  }
129
143
  // A step is pending, and its hand-off is the pause, while it is in flight.
130
144
  // With nothing in flight, a step the turn names that has not landed is
@@ -137,12 +151,14 @@ export function handoff(cwd, args = []) {
137
151
  // pointer, no decision pending and so no Your Call. The same two parts the
138
152
  // checkpoint printed for itself, from the same call.
139
153
  const landed = boundaryEnding(root, slug, current);
140
- return emit([landed.verdict, landed.pointer]);
154
+ return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.pointer]);
141
155
  }
142
156
  // The pause: the whole turn as one contiguous block, each part a labeled line
143
157
  // with one blank line between, and the only fence the readout's own (plus the
144
- // inline diff when the change is small enough). It opens on the Summary the
145
- // model wrote into the detail file, so there is no seam left to rule off.
158
+ // inline diff when the change is small enough). It opens on the step in
159
+ // flight and then the Summary the model wrote into the detail file, so there
160
+ // is no seam left to rule off.
161
+ const detail = settleDetail(root, rawDetail);
146
162
  const { rows, work, summary, checkGreen } = measured(root, slug, intent, detail, current);
147
163
  const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, current));
148
164
  const verdict = verdictLine(ladder, worst);
@@ -150,23 +166,105 @@ export function handoff(cwd, args = []) {
150
166
  const changed = counts.added + counts.removed;
151
167
  // The patch comes first so `· inline below` is a promise the fence keeps: a
152
168
  // small change git can still print nothing for (a vanished path) falls back
153
- // to the bare counts rather than pointing at a fence that never arrives.
154
- const patch = changed > 0 && changed <= INLINE_DIFF_MAX ? diffPatch(root, work) : '';
169
+ // to the bare counts rather than pointing at a fence that never arrives. The
170
+ // stamp hashes it at any size, so it is read whenever there is a change.
171
+ const fullPatch = changed > 0 ? diffPatch(root, work) : '';
172
+ const patch = changed <= INLINE_DIFF_MAX ? fullPatch : '';
155
173
  const inline = patch.length > 0;
174
+ const diffRow = diffRowValue(counts, inline);
175
+ const stamp = pauseStamp(current, detail, measuredLines(rows, diffRow), fullPatch);
176
+ if (!rest.includes('--full') && readOr(pausePath(root, slug)).trim() === stamp) {
177
+ // The pause the human already read, unchanged: the answer above this line
178
+ // is what they asked for, and the card would push it off the screen.
179
+ const command = reprintCommand(null, explicit, named);
180
+ return emit(root, { tier: 'decision', render: 'reminder', command }, [
181
+ `**Next Up**: Back to ${stepPhrase(current, steps)}`,
182
+ ]);
183
+ }
184
+ writePause(root, slug, stamp);
156
185
  const readout = recapLines(rows, {
157
- diff: diffRowValue(counts, inline),
186
+ diff: diffRow,
158
187
  spent: spentRowValue(spentInputs(root, slug, current, summary)),
159
188
  constraints: parseConstraintCount(intent),
160
189
  });
161
- const parts = [summaryBlock(parseSummary(detail), relative(root, detailPath(root)))];
190
+ const parts = [
191
+ `**Now**: ${stepPhrase(current, steps)}`,
192
+ summaryBlock(parseSummary(detail), relative(root, detailPath(root))),
193
+ ];
162
194
  if (readout.length > 0) {
163
- parts.push([readoutLabel(current, steps), '', ...fence('text', readout)].join('\n'));
195
+ parts.push([READOUT_LABEL, '', ...fence('text', readout)].join('\n'));
164
196
  }
165
197
  if (inline) {
166
198
  parts.push(fence('diff', patch.split('\n')).join('\n'));
167
199
  }
168
200
  parts.push(verdict, nextUpBlock(nextUp, steps.length, where, nextUpNote(intent, buildLog, current)), yourCallBlock(current, checkGreen));
169
- return emit(withRecommendation(parts, parseRecommendation(detail)));
201
+ const command = reprintCommand('--full', explicit, named);
202
+ return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
203
+ }
204
+ /**
205
+ * The detail file's text with a blank line around each heading, written back to
206
+ * disk when that changed anything, so the file the gate lints before
207
+ * `checkpoint` clears it is clean whatever the model typed.
208
+ *
209
+ * The render goes out on the normalized text either way. A failed write costs
210
+ * only the lint guard: stdout and the exit code are the same as if it had
211
+ * landed.
212
+ */
213
+ function settleDetail(root, raw) {
214
+ const clean = normalizeDetailHeadings(raw);
215
+ if (clean !== raw) {
216
+ try {
217
+ writeFileSync(detailPath(root), clean);
218
+ }
219
+ catch {
220
+ // Read-only tree or a vanished directory: the pause still renders.
221
+ }
222
+ }
223
+ return clean;
224
+ }
225
+ /**
226
+ * The stamp of a step pause: its number and a hash of everything on the card a
227
+ * later turn could change, so the reminder prints only while the pause is the
228
+ * one the human already read.
229
+ *
230
+ * Hashed: the detail file as normalized, the measured check and seam rows and
231
+ * the diff row as rendered, and the patch the diff row counts, so an edit that
232
+ * keeps the counts still reads as a touched file. Never `spent`: it counts
233
+ * turns, so it moves on every turn and would make every render look new.
234
+ */
235
+ function pauseStamp(step, detail, rendered, patch) {
236
+ const hash = createHash('sha256');
237
+ for (const part of [detail, ...rendered, patch]) {
238
+ hash.update(part).update('\0');
239
+ }
240
+ return `${step} ${hash.digest('hex')}`;
241
+ }
242
+ /**
243
+ * The readout rows the CLI measured, as the card renders them: `check`,
244
+ * `seam`, and `diff`. The judgment rows ride the detail file, which the stamp
245
+ * hashes whole, and `spent` stays out of it.
246
+ */
247
+ function measuredLines(rows, diff) {
248
+ const measuredRows = {};
249
+ if (rows.check !== undefined) {
250
+ measuredRows.check = rows.check;
251
+ }
252
+ if (rows.seam !== undefined) {
253
+ measuredRows.seam = rows.seam;
254
+ }
255
+ return recapLines(measuredRows, { diff, spent: null, constraints: 0 });
256
+ }
257
+ /**
258
+ * Record the stamp of the card just printed. A failed write costs one more
259
+ * full card on a later turn, never this turn's output or its exit code.
260
+ */
261
+ function writePause(root, slug, stamp) {
262
+ try {
263
+ writeFileSync(pausePath(root, slug), `${stamp}\n`);
264
+ }
265
+ catch {
266
+ // A read-only tree or a vanished build folder: the card still renders.
267
+ }
170
268
  }
171
269
  /**
172
270
  * The two CLI-rendered parts of a boundary ending: the Verdict folded from
@@ -195,6 +293,9 @@ export function boundaryEnding(root, slug, step) {
195
293
  * numbered sections' bodies, blank-line separated and unindented; the caller
196
294
  * nests it under the Log's dated line.
197
295
  *
296
+ * The record carries no Now line, and its Readout label stays bare like the
297
+ * card's: the dated Log line above it already names the step.
298
+ *
198
299
  * Measured, not recalled: the check, seam, and diff rows are read the same way
199
300
  * the pause read them. Both measures run against HEAD, so staging no longer
200
301
  * moves the counts and the caller's build-then-stage order is a nicety rather
@@ -224,7 +325,7 @@ export function stepRecord(root, slug, step, detail) {
224
325
  parts.push(recordSummary(summary));
225
326
  }
226
327
  if (readout.length > 0) {
227
- parts.push([readoutLabel(step, parseSteps(intent)), '', ...fence('text', readout)].join('\n'));
328
+ parts.push([READOUT_LABEL, '', ...fence('text', readout)].join('\n'));
228
329
  }
229
330
  parts.push(verdictLine(ladder, worst));
230
331
  return [...withRecommendation(parts, recommendation), ...sectionBlocks(sections)].join('\n\n');
@@ -275,7 +376,7 @@ export function driverPointer(root, slug) {
275
376
  // driver line itself stays one line, with no note beneath it.
276
377
  const steps = parseOrderedSteps(intent);
277
378
  const inFlight = readMarker(stepPath(root, slug));
278
- const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight, steps.length);
379
+ const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight);
279
380
  // The forward fallback skips the step just landed as well as the one open, so
280
381
  // a checkpoint whose `[x]` flip failed still points on rather than back at
281
382
  // work already recorded.
@@ -315,22 +416,33 @@ function measured(root, slug, intent, detail, step) {
315
416
  return { rows, work, summary, checkGreen: measuredCheck?.verdict === 'true' };
316
417
  }
317
418
  /**
318
- * Write an ending to stdout and return handoff's exit code.
419
+ * Print an ending, record it for the relay check, and return handoff's exit
420
+ * code.
319
421
  *
320
422
  * The parts are stacked by the same assembly a transition verb prints its own
321
423
  * ending through, so the block reads the same whichever command emitted it.
322
424
  */
323
- function emit(parts) {
324
- process.stdout.write(blocks(parts));
425
+ function emit(root, kind, parts) {
426
+ printEnding(root, { ...kind, text: blocks(parts) });
325
427
  return 0;
326
428
  }
429
+ /**
430
+ * The command that prints a decision ending again, for the retry the relay
431
+ * check asks for: handoff in the form that printed it, with the step a verify
432
+ * pause was named by and the build a flag named, so the retry lands on the
433
+ * same pause with nothing in flight and the cursor elsewhere.
434
+ */
435
+ function reprintCommand(form, step, build) {
436
+ const parts = ['plumbbob handoff', form, step, build === null ? null : `--build ${build}`];
437
+ return parts.filter((p) => p !== null && p !== undefined).join(' ');
438
+ }
327
439
  /**
328
440
  * The Verdict line from an already-folded result: the ladder rung, and the
329
441
  * worst component in a trailing parenthetical when one exists.
330
442
  *
331
- * No step segment rides here. The identity renders once per turn: the Readout
332
- * label names the step, Next Up carries the progress count, and the Verdict is
333
- * left saying the one thing only it says.
443
+ * No step segment rides here. The identity renders once per turn: the Now line
444
+ * names the step in flight with its count, Next Up names the step to come with
445
+ * its own, and the Verdict is left saying the one thing only it says.
334
446
  */
335
447
  function verdictLine(ladder, worst) {
336
448
  const state = `**Verdict**: ${ladder.glyph} ${ladder.state}`;
@@ -356,13 +468,26 @@ function summaryBlock(summary, where) {
356
468
  }
357
469
  return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
358
470
  }
471
+ // The Readout's label, bare on the card and in the record alike: the Now line
472
+ // at the top of the card names the step the fence measures, and a label that
473
+ // named it too would put the step on the card twice.
474
+ const READOUT_LABEL = '**Readout**:';
359
475
  /**
360
- * The Readout's label: the step the fence below it measures, named once.
476
+ * A step named the way every pointer names it: the number, the count out of
477
+ * the whole plan, and the title behind a plain hyphen.
478
+ *
479
+ * The count is the plan's own, step number over total, so the Now line and
480
+ * Next Up agree under a re-sequenced build order. It is dropped only where it
481
+ * would lie: a step the plan no longer holds is its number alone, since
482
+ * "Step 9 of 3" would be worse than no count.
361
483
  */
362
- function readoutLabel(step, steps) {
363
- const open = steps.find((s) => s.n === step);
364
- const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
365
- return `**Readout**: Step ${step}${title}`;
484
+ function stepPhrase(n, steps) {
485
+ const step = steps.find((s) => s.n === n);
486
+ if (step === undefined) {
487
+ return `Step ${n}`;
488
+ }
489
+ const title = step.title.length > 0 ? ` - ${step.title}` : '';
490
+ return `Step ${n} of ${steps.length}${title}`;
366
491
  }
367
492
  /**
368
493
  * What the `spent` row is rendered from: the step's own stamps and counters
@@ -467,16 +592,18 @@ function readCheckSummary(root) {
467
592
  *
468
593
  * Nobody types "needs work", so the block names what really happens instead:
469
594
  * a message that asks is an expand (nothing changes), and a message that
470
- * directs is the fix. `revert` comes last, because a destructive move is named
471
- * rather than discovered. `looks good` renders only while the measured check is
472
- * green; offering a move that would refuse teaches a false ceremony.
595
+ * directs is the fix. The expand move also names the way back to the card,
596
+ * since an expand turn ends on the reminder and the reminder states no move.
597
+ * `revert` comes last, because a destructive move is named rather than
598
+ * discovered. `looks good` renders only while the measured check is green;
599
+ * offering a move that would refuse teaches a false ceremony.
473
600
  */
474
601
  function yourCallBlock(step, checkGreen) {
475
602
  const moves = [];
476
603
  if (checkGreen) {
477
604
  moves.push(callLine('`looks good`', `I checkpoint Step ${step}; back to the boundary`));
478
605
  }
479
- moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes'));
606
+ 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'));
480
607
  moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
481
608
  moves.push(callLine('`revert`', 'I wind the work back to the last checkpoint'));
482
609
  return ['**Your Call**:', '', ...moves].join('\n');
@@ -511,16 +638,8 @@ function planCallBlock(first) {
511
638
  * step is already being built, so the `/model` call is behind us. Null when no
512
639
  * step is open, which is the caller's cue to fall back to the forward pointer.
513
640
  */
514
- function driverNextUpLine(steps, inFlight, total) {
515
- if (inFlight === null) {
516
- return null;
517
- }
518
- const open = steps.find((s) => s.n === inFlight);
519
- const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
520
- // The progress count rides every tier's pointer, but only where the plan
521
- // actually holds the step: "step 9 of 3" would be worse than no count.
522
- const progress = open === undefined ? '' : ` of ${total}`;
523
- return `**Next Up**: Back to Step ${inFlight}${progress}${title}`;
641
+ function driverNextUpLine(steps, inFlight) {
642
+ return inFlight === null ? null : `**Next Up**: Back to ${stepPhrase(inFlight, steps)}`;
524
643
  }
525
644
  /**
526
645
  * The driver pointer while a spike is open: the spike outranks the step it
@@ -14,7 +14,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
14
14
  import { findRepoRoot } from "../lib/git.js";
15
15
  import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
16
16
  import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
17
- import { ending, notice, transition } from "../lib/notice.js";
17
+ import { ending, notice, printEnding, transition } from "../lib/notice.js";
18
18
  import { driverPointer } from "./handoff.js";
19
19
  /**
20
20
  * Set the build order to the given step numbers, the rest of the undone steps
@@ -35,10 +35,14 @@ export function order(cwd, args) {
35
35
  const intent = readFileSync(path, 'utf8');
36
36
  if (args.includes('--reset')) {
37
37
  writeFileSync(path, clearBuildOrder(intent));
38
- process.stdout.write(ending({
39
- lead: transition({ label: 'Build order', fact: 'document order' }),
40
- pointer: driverPointer(root, activeBuild(root)),
41
- }));
38
+ printEnding(root, {
39
+ tier: 'driver',
40
+ render: 'order',
41
+ text: ending({
42
+ lead: transition({ label: 'Build order', fact: 'document order' }),
43
+ pointer: driverPointer(root, activeBuild(root)),
44
+ }),
45
+ });
42
46
  return 0;
43
47
  }
44
48
  // Numbers arrive space- or comma-separated: `order 7 8 5` and `order 7,8,5`
@@ -94,9 +98,13 @@ export function order(cwd, args) {
94
98
  // The pointer reads the plan back from disk, so it already follows the
95
99
  // order just written: back at the step in flight, or forward from the
96
100
  // boundary to the first step in the new sequence.
97
- process.stdout.write(ending({
98
- lead: transition({ label: 'Build order', fact: full.join(', ') }),
99
- pointer: driverPointer(root, activeBuild(root)),
100
- }));
101
+ printEnding(root, {
102
+ tier: 'driver',
103
+ render: 'order',
104
+ text: ending({
105
+ lead: transition({ label: 'Build order', fact: full.join(', ') }),
106
+ pointer: driverPointer(root, activeBuild(root)),
107
+ }),
108
+ });
101
109
  return 0;
102
110
  }
@@ -11,7 +11,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
11
11
  import { findRepoRoot } from "../lib/git.js";
12
12
  import { activeBuild, hasSession, buildLogPath } from "../lib/sidecar.js";
13
13
  import { appendToSection } from "../lib/buildlog.js";
14
- import { ending, notice, transition } from "../lib/notice.js";
14
+ import { ending, notice, printEnding, transition } from "../lib/notice.js";
15
15
  import { driverPointer } from "./handoff.js";
16
16
  /**
17
17
  * Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
@@ -43,6 +43,10 @@ export function park(cwd, args) {
43
43
  writeFileSync(path, updated);
44
44
  // A park interrupts a step without ending it, so the ending is the capture
45
45
  // and the pointer back at what was interrupted.
46
- process.stdout.write(ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }));
46
+ printEnding(root, {
47
+ tier: 'driver',
48
+ render: 'park',
49
+ text: ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }),
50
+ });
47
51
  return 0;
48
52
  }