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.
@@ -1,23 +1,71 @@
1
- // `plumbbob handoff`: print the standardized build hand-off block: the
2
- // "state / choice / what's next" the human sees at each step boundary. Read-only,
3
- // no state change. It derives the moment from the session: a step in flight ⇒ the
4
- // pause block (built → looks-good/needs-work → next), none ⇒ the post-checkpoint
5
- // boundary block, and renders the next undone step plus its advisory `- model:`
6
- // recommendation straight from intent.md, the build's plan document. Owning the
7
- // block here, rather than as prose in the build skill, keeps the skill from
8
- // drifting out of sync with `status`, which renders the same next-step detail
9
- // (both read `parseSteps`).
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
3
+ // the readout fence is a bold label, a colon, and text that wraps, one blank
4
+ // 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
+ // with none in flight ⇒ the orientation-tier ending (Verdict and Next Up); a
9
+ // fresh session with nothing yet measured ⇒ the forward pointer alone.
10
+ //
11
+ // The whole decision turn is rendered here, its opening block included: the
12
+ // Summary lead and the numbered highlights come out of `.plumbbob/detail.md`
13
+ // as the markdown the model wrote, with the `(details: …)` bracket appended so
14
+ // the model never types a path. A region the model does not author is a region
15
+ // it cannot narrate into.
16
+ //
17
+ // If the CLI can compute a recap row, the CLI does: the check row comes from
18
+ // the last run's summary, the seam row from the SEAM marker against the
19
+ // work-plane diff, the diff row from `git diff --numstat`. The model's part
20
+ // (`.plumbbob/detail.md`) supplies only what takes judgment: the Summary, the
21
+ // three judgment rows, and the recommendation; the Verdict folds the same
22
+ // assembled rows worst-of with the step's accrued stats, so the fence shows
23
+ // exactly what the fold saw.
24
+ //
25
+ // Every tier's ending is emitted here, so no skill has to fake the furniture in
26
+ // prose: `--plan` renders the plan-pause ending and `--driver` the driver
27
+ // turn's pointer, the two endings the session state cannot tell apart from the
28
+ // ones above. The driver pointer reads the phase it lands in: a spike open over
29
+ // the step names closing the spike, a step exit (a revert, an abandon, a spike
30
+ // closed at the boundary) finds nothing in flight and points forward, with no
31
+ // Verdict either way, since nothing landed.
10
32
  import { readFileSync } from 'node:fs';
11
- import { findRepoRoot } from "../lib/git.js";
12
- import { checkpointsPath, hasSession, intentPath, resolveBuild, stepPath } from "../lib/sidecar.js";
13
- import { parseLastCheckpoint, parseSteps } from "../lib/orient.js";
33
+ import { join, relative } from 'node:path';
34
+ import { commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
35
+ import { blocks } from "../lib/notice.js";
36
+ import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
37
+ import { checkpointsPath, detailPath, hasSession, inSpike, intentPath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
38
+ import { countDiff, diffRowValue, foldVerdict, lastLedgerSha, parseConstraintCount, parseLastCheckpoint, parseRecap, parseRecommendation, parseSteps, parseSummary, recapLines, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
39
+ // The inline-diff threshold (docs/presentation.md): at 20 changed lines or
40
+ // fewer the patch rides the turn; at 21 it stays in the working tree behind
41
+ // the counted diff row.
42
+ 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
+ // The seam rule, and the one tier that still has a seam to mark: at the plan
49
+ // pause the model presents the framed plan above the relay, so the rule keeps
50
+ // a label from reading as the tail of what precedes it, and a run of blank
51
+ // lines collapses to one in every markdown renderer where a thematic break
52
+ // renders as space. The blank line above it matters: `---` flush under a text
53
+ // line turns that line into a heading (the underline form), not a rule. The
54
+ // step pause needs none: handoff renders that whole turn, Summary included.
55
+ const SEAM_RULE = '\n---';
14
56
  /**
15
- * Print the hand-off block for the resolved build; return the exit code.
57
+ * Print the resolved build's turn ending; return the exit code.
16
58
  *
17
- * Requires an active session (the STATE sentinel under `.plumbbob/`). Which
18
- * moment to render is derived, not passed: a step in flight yields the pause
19
- * block, none yields the boundary block, and a fresh session with nothing to
20
- * report yields only the forward pointer.
59
+ * Requires an active session (the STATE sentinel under `.plumbbob/`). The step
60
+ * 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), a landed
63
+ * step yields the orientation-tier ending (Verdict and Next Up, no Your Call),
64
+ * and a fresh session with nothing to report yields only
65
+ * the forward pointer. The two endings no session state can distinguish are
66
+ * named by a flag: `--plan` is the plan pause (a decision turn about the plan,
67
+ * not a diff) and `--driver` is a driver turn (a park, a spike, a `use`), which
68
+ * interrupts a step without ending it.
21
69
  */
22
70
  export function handoff(cwd, args = []) {
23
71
  const root = findRepoRoot(cwd);
@@ -26,8 +74,25 @@ export function handoff(cwd, args = []) {
26
74
  return 1;
27
75
  }
28
76
  const { build: slug, rest } = resolveBuild(root, args);
29
- const steps = parseSteps(readOr(intentPath(root, slug)));
30
- const inFlight = readStep(stepPath(root, slug));
77
+ if (rest.includes('--plan') && rest.includes('--driver')) {
78
+ process.stderr.write('plumbbob: handoff: --plan and --driver name different tiers; pass one.\n');
79
+ return 1;
80
+ }
81
+ const intent = readOr(intentPath(root, slug));
82
+ const steps = parseSteps(intent);
83
+ const inFlight = readMarker(stepPath(root, slug));
84
+ const detail = readOr(detailPath(root));
85
+ // Where a pointer sends the reader to read the step itself: the intent file
86
+ // as the human would type it, relative to the repo root.
87
+ const where = relative(root, intentPath(root, slug));
88
+ if (rest.includes('--plan')) {
89
+ // The plan pause judges the plan, so nothing is measured and no Verdict
90
+ // renders; the pointer and the moves both aim at the first undone step. A
91
+ // decision turn still ends on the model's recommendation when it wrote one.
92
+ const first = steps.find((s) => !s.done);
93
+ const plan = [SEAM_RULE, nextUpLine(first, steps.length, where), planCallBlock(first)];
94
+ return emit(withRecommendation(plan, parseRecommendation(detail)));
95
+ }
31
96
  const explicit = rest.find((a) => /^\d+$/.test(a));
32
97
  const lastDone = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
33
98
  // The step this hand-off is about: an explicit arg wins (the skill's override),
@@ -40,70 +105,367 @@ export function handoff(cwd, args = []) {
40
105
  // so the `!== current` clause is a harmless no-op. Skipping-ahead builds still
41
106
  // land on the earliest remaining step, never a gap.
42
107
  const nextUp = steps.find((s) => !s.done && s.n !== current);
108
+ if (rest.includes('--driver')) {
109
+ // A driver turn interrupts a step without ending it, so its pointer aims
110
+ // back at the step still open, or at the spike open over it. With neither
111
+ // (a step exit: a revert, an abandon, a spike closed at the boundary) the
112
+ // ordinary forward pointer stands, and no Verdict rides above it: nothing
113
+ // landed to measure.
114
+ return emit([driverPointer(root, slug)]);
115
+ }
43
116
  if (current === null) {
44
- // No step to report as just-done (fresh session): emit only the forward pointer.
45
- process.stdout.write(`${nextUpLine(nextUp)}\n`);
46
- return 0;
117
+ // Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
118
+ return emit([nextUpLine(nextUp, steps.length, where)]);
119
+ }
120
+ if (inFlight === null) {
121
+ // The boundary: the orientation-tier ending, the state word and the forward
122
+ // pointer, no decision pending and so no Your Call. The same two parts the
123
+ // checkpoint printed for itself, from the same call.
124
+ const landed = boundaryEnding(root, slug, current);
125
+ return emit([landed.verdict, landed.pointer]);
126
+ }
127
+ // The pause: the whole turn as one contiguous block, each part a labeled line
128
+ // with one blank line between, and the only fence the readout's own (plus the
129
+ // inline diff when the change is small enough). It opens on the Summary the
130
+ // model wrote into the detail file, so there is no seam left to rule off.
131
+ const { rows, work, summary, checkGreen } = measured(root, slug, intent, detail, current);
132
+ const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, current));
133
+ const verdict = verdictLine(ladder, worst);
134
+ const counts = countDiff(work);
135
+ const changed = counts.added + counts.removed;
136
+ const inline = changed > 0 && changed <= INLINE_DIFF_MAX;
137
+ const readout = recapLines(rows, {
138
+ diff: diffRowValue(counts, inline),
139
+ spent: spentRowValue(spentInputs(root, slug, current, summary)),
140
+ constraints: parseConstraintCount(intent),
141
+ });
142
+ const parts = [summaryBlock(parseSummary(detail), relative(root, detailPath(root)))];
143
+ if (readout.length > 0) {
144
+ parts.push([readoutLabel(current, steps), '', ...fence('text', readout)].join('\n'));
145
+ }
146
+ if (inline) {
147
+ const patch = diffPatch(root, work.map((e) => e.path));
148
+ if (patch.length > 0) {
149
+ parts.push(fence('diff', patch.split('\n')).join('\n'));
150
+ }
151
+ }
152
+ parts.push(verdict, nextUpLine(nextUp, steps.length, where), yourCallBlock(current, checkGreen));
153
+ return emit(withRecommendation(parts, parseRecommendation(detail)));
154
+ }
155
+ /**
156
+ * The two CLI-rendered parts of a boundary ending: the Verdict folded from
157
+ * everything measurable right now, and the pointer at the step to come.
158
+ *
159
+ * `step` is the step that just landed, or null at a plan commit, where nothing
160
+ * was measured and the Verdict vanishes. A transition verb prints these beneath
161
+ * its own lead line, so a boundary reads identically whether `checkpoint`
162
+ * emitted it or a later `handoff` did; the paths a stray advisory named are the
163
+ * one part that does not survive, and the commit holds those.
164
+ */
165
+ export function boundaryEnding(root, slug, step) {
166
+ const intent = readOr(intentPath(root, slug));
167
+ const steps = parseSteps(intent);
168
+ const nextUp = steps.find((s) => !s.done && s.n !== step);
169
+ return {
170
+ verdict: step === null ? null : landedVerdict(root, slug, intent, step),
171
+ pointer: nextUpLine(nextUp, steps.length, relative(root, intentPath(root, slug))),
172
+ };
173
+ }
174
+ /**
175
+ * The driver turn's pointer: back at the step still open, at the spike raised
176
+ * over it, or forward from the boundary when the step has already exited.
177
+ *
178
+ * A park or a spike interrupts a step without ending it, so nothing landed and
179
+ * no Verdict rides above this line.
180
+ */
181
+ export function driverPointer(root, slug) {
182
+ const intent = readOr(intentPath(root, slug));
183
+ const steps = parseSteps(intent);
184
+ const inFlight = readMarker(stepPath(root, slug));
185
+ const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight, steps.length);
186
+ // The forward fallback skips the step just landed as well as the one open, so
187
+ // a checkpoint whose `[x]` flip failed still points on rather than back at
188
+ // work already recorded.
189
+ const landed = parseLastCheckpoint(readOr(checkpointsPath(root, slug)))?.n ?? null;
190
+ const forward = steps.find((s) => !s.done && s.n !== (inFlight ?? landed));
191
+ return driver ?? nextUpLine(forward, steps.length, relative(root, intentPath(root, slug)));
192
+ }
193
+ /**
194
+ * The Verdict for a step that just landed: the assembled rows folded worst-of
195
+ * with the step's accrued stats, rendered as the line.
196
+ */
197
+ function landedVerdict(root, slug, intent, step) {
198
+ const { rows } = measured(root, slug, intent, readOr(detailPath(root)), step);
199
+ const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, step));
200
+ return verdictLine(ladder, worst);
201
+ }
202
+ /**
203
+ * Assemble the rows once, so the fence and the fold read the same set: the
204
+ * model's attested rows, with the check and seam rows replaced by the CLI's own
205
+ * measurements where one exists (measured beats attested).
206
+ *
207
+ * `checkGreen` reports the *measured* check alone. A move offered on an
208
+ * attested green would be a move the gate then refuses.
209
+ */
210
+ function measured(root, slug, intent, detail, step) {
211
+ const summary = readCheckSummary(root);
212
+ const measuredCheck = summary === null ? null : summaryCheckRow(summary);
213
+ const work = diffNumstat(root).filter((e) => !isArtifactPath(e.path));
214
+ const rows = { ...(parseRecap(detail)?.rows ?? {}) };
215
+ if (measuredCheck !== null) {
216
+ rows.check = measuredCheck;
47
217
  }
48
- const block = inFlight !== null ? pauseBlock(current, titleOf(steps, current), nextUp) : boundaryBlock(current, titleOf(steps, current), nextUp);
49
- process.stdout.write(`${block}\n`);
218
+ const measuredSeam = seamRowFromDiff(work.map((e) => e.path), seamTokens(root, slug, intent, step));
219
+ if (measuredSeam !== null) {
220
+ rows.seam = measuredSeam;
221
+ }
222
+ return { rows, work, summary, checkGreen: measuredCheck?.verdict === 'true' };
223
+ }
224
+ /**
225
+ * Write an ending to stdout and return handoff's exit code.
226
+ *
227
+ * The parts are stacked by the same assembly a transition verb prints its own
228
+ * ending through, so the block reads the same whichever command emitted it.
229
+ */
230
+ function emit(parts) {
231
+ process.stdout.write(blocks(parts));
50
232
  return 0;
51
233
  }
52
234
  /**
53
- * The pause block: the step is built and awaiting approval.
235
+ * The Verdict line from an already-folded result: the ladder rung, and the
236
+ * worst component in a trailing parenthetical when one exists.
54
237
  *
55
- * The opener is neutral (the CLI cannot vouch for the diff), pointing at the
56
- * diff and self-review the skill shows above it.
238
+ * No step segment rides here. The identity renders once per turn: the Readout
239
+ * label names the step, Next Up carries the progress count, and the Verdict is
240
+ * left saying the one thing only it says.
57
241
  */
58
- function pauseBlock(step, title, nextUp) {
59
- return [
60
- `Step ${step}${paren(title)} is built. Review the diff and self-review above, then:`,
61
- ` - looks good → I'll checkpoint it (lands step ${step} as its own commit, back to the boundary)`,
62
- ` - needs work → tell me what to change and I'll iterate on this same step`,
63
- '',
64
- nextUpLine(nextUp),
65
- ].join('\n');
242
+ function verdictLine(ladder, worst) {
243
+ const state = `**Verdict**: ${ladder.glyph} ${ladder.state}`;
244
+ return worst === null ? state : `${state} (${worst})`;
66
245
  }
67
246
  /**
68
- * The boundary block: the checkpoint has landed and the loop is back at the
69
- * step boundary, pointing at what to build next.
247
+ * The turn's opening block: the model's lead behind the Summary label, with the
248
+ * `(details: …)` bracket appended, then the numbered highlights beneath it.
249
+ *
250
+ * The lead and the titles are the markdown the model wrote, passed through; the
251
+ * label, the bracket, and the numbering are the CLI's, so the model never types
252
+ * a path and the block cannot drift from where the detail actually is. Empty
253
+ * when the detail file carries no lead: a Summary that says nothing vanishes
254
+ * rather than labeling nothing.
70
255
  */
71
- function boundaryBlock(step, title, nextUp) {
72
- return [`Step ${step}${paren(title)} checkpointed — back at the boundary.`, '', nextUpLine(nextUp)].join('\n');
256
+ function summaryBlock(summary, where) {
257
+ if (summary === null) {
258
+ return '';
259
+ }
260
+ const lead = `**Summary**: ${summary.lead} (details: \`${where}\`)`;
261
+ if (summary.highlights.length === 0) {
262
+ return lead;
263
+ }
264
+ return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
73
265
  }
74
266
  /**
75
- * The forward pointer, shared by both blocks: the next undone step, its title,
76
- * and its advisory `- model:` recommendation.
267
+ * The Readout's label: the step the fence below it measures, named once.
268
+ */
269
+ function readoutLabel(step, steps) {
270
+ const open = steps.find((s) => s.n === step);
271
+ const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
272
+ return `**Readout**: Step ${step}${title}`;
273
+ }
274
+ /**
275
+ * What the `spent` row is rendered from: the step's own stamps and counters
276
+ * from stats.json, the turns the ledger counted across the step (TURN minus
277
+ * the TICK stamped on entry), and the last gate's wall clock.
77
278
  *
78
- * The recommendation renders as just the model token for the `/model` call;
79
- * the full rationale lives on the `status` dashboard. No next step ⇒ the
80
- * finish/step nudge instead.
279
+ * A host with no turn hook grows no ledger, so `turns` comes back null there
280
+ * and the clause simply vanishes rather than reading zero.
81
281
  */
82
- function nextUpLine(nextUp) {
83
- if (nextUp === undefined) {
84
- return 'No planned steps remain — /plumbbob:step to add an increment, or /plumbbob:finish.';
282
+ function spentInputs(root, slug, step, summary) {
283
+ const stats = readStats(root, slug)[String(step)] ?? {};
284
+ const turn = readTurn(root);
285
+ const tick = readMarker(tickPath(root, slug));
286
+ return {
287
+ startedAt: stats.startedAt,
288
+ landedAt: stats.landedAt,
289
+ now: Date.now(),
290
+ turns: turn === null || tick === null ? null : Math.max(0, turn - tick),
291
+ redChecks: stats.redChecks ?? 0,
292
+ gateMs: summary?.total_duration_ms ?? null,
293
+ driftWarnings: stats.driftWarnings ?? 0,
294
+ };
295
+ }
296
+ /**
297
+ * The seam tokens governing `step`: the SEAM marker `build` wrote is
298
+ * authoritative while a build is live, else the step's declared seam parsed
299
+ * from intent.md. Empty when neither resolves, which vanishes the measured
300
+ * seam row rather than flagging the whole tree.
301
+ */
302
+ function seamTokens(root, slug, intent, step) {
303
+ try {
304
+ const fromFile = readFileSync(seamPath(root, slug), 'utf8')
305
+ .split('\n')
306
+ .map((l) => l.trim())
307
+ .filter((l) => l.length > 0);
308
+ if (fromFile.length > 0) {
309
+ return fromFile;
310
+ }
85
311
  }
86
- const head = `Next up: step ${nextUp.n}${paren(nextUp.title)}`;
87
- const token = modelToken(nextUp.model);
88
- return token === null ? `${head} — /plumbbob:build to start it.` : `${head} · model: ${token} — /model ${token} then /plumbbob:build.`;
312
+ catch {
313
+ // no SEAM marker: fall through to the declared seam.
314
+ }
315
+ const parsed = parseStepSeam(intent, step);
316
+ return parsed.ok ? parsed.seam : [];
89
317
  }
90
318
  /**
91
- * A step's title in parentheses, or nothing when the title is empty.
319
+ * Wrap lines in a markdown fence whose rail outruns any backtick run inside,
320
+ * so a patch that itself contains fence markers cannot break out of the
321
+ * `diff` fence it rides in.
92
322
  */
93
- function paren(title) {
94
- return title !== null && title.length > 0 ? ` (${title})` : '';
323
+ function fence(lang, lines) {
324
+ const longest = lines.reduce((max, l) => Math.max(max, ...(l.match(/`+/g) ?? ['']).map((run) => run.length)), 0);
325
+ const rail = '`'.repeat(Math.max(3, longest + 1));
326
+ return [`${rail}${lang}`, ...lines, rail];
95
327
  }
328
+ // The recommendation's label (docs/presentation.md): the one bold token the
329
+ // turn body spends, prepended here so the model never types it.
330
+ const RECOMMENDATION_LABEL = '**Recommendation**: ';
96
331
  /**
97
- * The title of step `n` from the parsed steps, or null when the step is
98
- * unknown or untitled.
332
+ * Append the recommendation beneath a block, blank-line separated, when the
333
+ * model wrote one; the ending's last text is then the model's own call. The
334
+ * label is the CLI's: it announces what the last text is before the eye
335
+ * reads it, and a label the model typed would be one more line to drift.
99
336
  */
100
- function titleOf(steps, n) {
101
- return steps.find((s) => s.n === n)?.title ?? null;
337
+ function withRecommendation(parts, recommendation) {
338
+ return recommendation === null ? [...parts] : [...parts, `${RECOMMENDATION_LABEL}${recommendation}`];
339
+ }
340
+ /**
341
+ * The step's accrued stats (seam strays, red checks, reverts) plus commits that
342
+ * landed since the last ledger entry outside plumbbob's checkpoints: the
343
+ * advisory inputs to the Verdict's third fold rung.
344
+ */
345
+ function accruedStats(root, slug, step) {
346
+ const stats = readStats(root, slug)[String(step)] ?? {};
347
+ const anchor = lastLedgerSha(readOr(checkpointsPath(root, slug)));
348
+ return {
349
+ driftWarnings: stats.driftWarnings ?? 0,
350
+ redChecks: stats.redChecks ?? 0,
351
+ reverts: stats.reverts ?? 0,
352
+ outOfBand: anchor === null ? 0 : commitsSince(root, anchor, PLAN_COMMIT_MARKER),
353
+ };
354
+ }
355
+ /**
356
+ * The last `plumbbob check` run's `.check/summary.json`, or null when it is
357
+ * absent or unreadable (nothing to measure with; the fold then falls back to
358
+ * the readout's attested row, if the model wrote one).
359
+ *
360
+ * Read once: the check row is measured from it, and so is the gate's wall
361
+ * clock in the `spent` row.
362
+ */
363
+ function readCheckSummary(root) {
364
+ try {
365
+ return JSON.parse(readFileSync(join(root, '.check', 'summary.json'), 'utf8'));
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ /**
372
+ * The Your Call block: the moves a human actually makes at a pause, each
373
+ * quoting what they say and stating what happens next.
374
+ *
375
+ * Nobody types "needs work", so the block names what really happens instead:
376
+ * a message that asks is an expand (nothing changes), and a message that
377
+ * directs is the fix. `revert` comes last, because a destructive move is named
378
+ * rather than discovered. `looks good` renders only while the measured check is
379
+ * green; offering a move that would refuse teaches a false ceremony.
380
+ */
381
+ function yourCallBlock(step, checkGreen) {
382
+ const moves = [];
383
+ if (checkGreen) {
384
+ moves.push(callLine('`looks good`', `I checkpoint Step ${step}; back to the boundary`));
385
+ }
386
+ moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes'));
387
+ moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
388
+ moves.push(callLine('`revert`', 'I wind the work back to the last checkpoint'));
389
+ return ['**Your Call**:', '', ...moves].join('\n');
390
+ }
391
+ /**
392
+ * One Your Call row: a list item naming the move, then the outcome clause
393
+ * behind the arrow, opening with a capital letter. The move stays lowercase
394
+ * because it quotes what the human says.
395
+ */
396
+ function callLine(move, outcome) {
397
+ return `- ${move} → ${outcome}`;
398
+ }
399
+ /**
400
+ * The plan pause's Your Call block: the same shape with the moves that apply
401
+ * there. Nothing is recorded yet, so `revert` has nothing to wind back to and
402
+ * vanishes; `looks good` names the step the plan starts at, which is Step 1 at
403
+ * the plan pause and the first undone step after a mid-build refine.
404
+ */
405
+ function planCallBlock(first) {
406
+ const starts = first === undefined ? '' : `; /plumbbob:build starts Step ${first.n}`;
407
+ return [
408
+ '**Your Call**:',
409
+ '',
410
+ callLine('`looks good`', `I mark the plan decided${starts}`),
411
+ callLine('`expand`, or any question', 'I show more of what is there; nothing changes'),
412
+ callLine('anything that reads as direction', 'I take it as what to sharpen; the plan is cheap to change now'),
413
+ ].join('\n');
414
+ }
415
+ /**
416
+ * The driver turn's pointer: back to the step still in flight, since a park or
417
+ * a spike interrupts a step without ending it. No model clause rides here; the
418
+ * step is already being built, so the `/model` call is behind us. Null when no
419
+ * step is open, which is the caller's cue to fall back to the forward pointer.
420
+ */
421
+ function driverNextUpLine(steps, inFlight, total) {
422
+ if (inFlight === null) {
423
+ return null;
424
+ }
425
+ const open = steps.find((s) => s.n === inFlight);
426
+ const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
427
+ // The progress count rides every tier's pointer, but only where the plan
428
+ // actually holds the step: "step 9 of 3" would be worse than no count.
429
+ const progress = open === undefined ? '' : ` of ${total}`;
430
+ return `**Next Up**: Back to Step ${inFlight}${progress}${title}`;
431
+ }
432
+ /**
433
+ * The driver pointer while a spike is open: the spike outranks the step it
434
+ * interrupted, so the move named is closing it and the step to come back to
435
+ * rides as a trailing clause.
436
+ *
437
+ * No progress count rides that clause. The count belongs to the step a pointer
438
+ * aims at, and this one aims at the spike; with nothing in flight (a spike
439
+ * opened at the boundary) the clause vanishes and the move stands alone.
440
+ */
441
+ function spikeNextUpLine(inFlight) {
442
+ const back = inFlight === null ? '' : `, then back to Step ${inFlight}`;
443
+ return `**Next Up**: Close the spike - /plumbbob:spike done${back}`;
444
+ }
445
+ /**
446
+ * The forward pointer: the next undone step with the progress count, its
447
+ * title, and a closing bracket carrying its advisory `- model:` recommendation
448
+ * and where to read the step in full. No next step ⇒ the finish/step nudge
449
+ * instead.
450
+ *
451
+ * The model is the second bold token the line spends, because it is the one
452
+ * the human acts on (a `/model` call) before the next run; the path is a bare
453
+ * `path:line` in a code span, the one link form that opens in a host and still
454
+ * reads as a path in a PR diff.
455
+ */
456
+ function nextUpLine(nextUp, total, where) {
457
+ if (nextUp === undefined) {
458
+ return '**Next Up**: Nothing planned - /plumbbob:step or /plumbbob:finish';
459
+ }
460
+ const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
461
+ const token = modelToken(nextUp.model);
462
+ const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
463
+ bracket.push(`details: \`${where}:${nextUp.line}\``);
464
+ return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
102
465
  }
103
466
  /**
104
467
  * The model token from a `- model:` recommendation: the first word, so a
105
- * `model: opus` line followed by an em dash and rationale yields `opus` for
106
- * the `/model` call.
468
+ * `model: opus` line followed by an em dash and rationale yields `opus`.
107
469
  *
108
470
  * Null when there is no recommendation, or it degraded to whitespace (folding
109
471
  * the null model into the same guard keeps both branches live).
@@ -112,6 +474,13 @@ function modelToken(model) {
112
474
  const first = model?.trim().split(/\s+/)[0];
113
475
  return first !== undefined && first.length > 0 ? first : null;
114
476
  }
477
+ /**
478
+ * Capitalize a token's first letter for the card's title-case furniture
479
+ * (`(model: Sonnet)`); the rest rides through unchanged.
480
+ */
481
+ function capitalize(s) {
482
+ return s.length === 0 ? s : `${s[0]?.toUpperCase()}${s.slice(1)}`;
483
+ }
115
484
  /**
116
485
  * Read a file as UTF-8, or return '' when it is missing or unreadable: an
117
486
  * absent sidecar file reads as empty rather than throwing.
@@ -125,11 +494,11 @@ function readOr(path) {
125
494
  }
126
495
  }
127
496
  /**
128
- * The in-flight step number from the STEP marker: the flat one-line file in
129
- * the build folder that records which step is open. Null when no step is in
130
- * flight or the file holds anything but a bare number.
497
+ * The count in a one-line marker file: STEP (which step is open) and TICK (the
498
+ * turn the step was entered on) are both written this way. Null when the file
499
+ * is absent or holds anything but a bare number.
131
500
  */
132
- function readStep(path) {
501
+ function readMarker(path) {
133
502
  try {
134
503
  const raw = readFileSync(path, 'utf8').trim();
135
504
  return /^\d+$/.test(raw) ? Number(raw) : null;
@@ -3,12 +3,16 @@
3
3
  // build-log.md (the build's human-facing ledger). Capture stays dumb by design:
4
4
  // a grep-readable append, no markdown parsing, no model turn (wording the line
5
5
  // well is /plumbbob:park's job); triage waits for a step boundary, via harvest.
6
+ // The capture prints its whole ending, the line and the pointer back at the
7
+ // step it interrupted, so the relay is one command.
6
8
  // Capture is not a state transition, so it runs in any context (terminal or
7
9
  // in-session).
8
10
  import { readFileSync, writeFileSync } from 'node:fs';
9
11
  import { findRepoRoot } from "../lib/git.js";
10
- import { hasSession, buildLogPath } from "../lib/sidecar.js";
12
+ import { activeBuild, hasSession, buildLogPath } from "../lib/sidecar.js";
11
13
  import { appendToSection } from "../lib/buildlog.js";
14
+ import { ending, notice, transition } from "../lib/notice.js";
15
+ import { driverPointer } from "./handoff.js";
12
16
  /**
13
17
  * Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
14
18
  *
@@ -19,7 +23,7 @@ import { appendToSection } from "../lib/buildlog.js";
19
23
  export function park(cwd, args) {
20
24
  const root = findRepoRoot(cwd);
21
25
  if (root === null || !hasSession(root)) {
22
- process.stderr.write('plumbbob: no active session — nothing to park to. Run `plumbbob start "<title>"` first.\n');
26
+ process.stderr.write(notice({ fact: 'no active session', detail: ['nothing to park to'], remedy: 'plumbbob start "<title>"' }));
23
27
  return 1;
24
28
  }
25
29
  const text = args
@@ -27,16 +31,18 @@ export function park(cwd, args) {
27
31
  .join(' ')
28
32
  .trim();
29
33
  if (text.length === 0) {
30
- process.stderr.write('plumbbob: park needs text. Try: plumbbob park "the idea you do not want to chase right now".\n');
34
+ process.stderr.write(notice({ fact: 'park needs text', remedy: 'plumbbob park "the idea you do not want to chase right now"' }));
31
35
  return 1;
32
36
  }
33
37
  const path = buildLogPath(root);
34
38
  const updated = appendToSection(readFileSync(path, 'utf8'), 'Park list', `- [ ] ${text}`);
35
39
  if (updated === null) {
36
- process.stderr.write('plumbbob: could not find a "## Park list" section in build-log.md.\n');
40
+ process.stderr.write(notice({ fact: 'no "## Park list" section in build-log.md', remedy: 'add the section, then park again' }));
37
41
  return 1;
38
42
  }
39
43
  writeFileSync(path, updated);
40
- process.stdout.write(`parked: ${text}\n`);
44
+ // A park interrupts a step without ending it, so the ending is the capture
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)) }));
41
47
  return 0;
42
48
  }