plumbbob 0.10.3 → 0.11.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.
@@ -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, parseSections, 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,441 @@ 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 step's record for the build-log's Log: the pause as the human approved
176
+ * it, with the full story behind each highlight beneath. The Summary and its
177
+ * highlights, the Readout fence, the Verdict, the recommendation, then the
178
+ * numbered sections' bodies, blank-line separated and unindented; the caller
179
+ * nests it under the Log's dated line.
180
+ *
181
+ * Measured, not recalled: the check, seam, and diff rows are read the same way
182
+ * the pause read them, so this has to be built before the work is staged (the
183
+ * seam and diff rows read the working tree). Null when the detail file carries
184
+ * nothing to record, so a hand-built step with no detail gets the bare dated
185
+ * line it always did.
186
+ */
187
+ export function stepRecord(root, slug, step, detail) {
188
+ const summary = parseSummary(detail);
189
+ const recommendation = parseRecommendation(detail);
190
+ const sections = parseSections(detail);
191
+ if (summary === null && recommendation === null && sections.length === 0 && parseRecap(detail) === null) {
192
+ // Nothing on the wire's shape: a blank file records nothing, and anything
193
+ // else the model wrote is kept verbatim rather than lost with the file.
194
+ const raw = detail.trim();
195
+ return raw.length === 0 ? null : raw;
196
+ }
197
+ const intent = readOr(intentPath(root, slug));
198
+ const { rows, work, summary: check } = measured(root, slug, intent, detail, step);
199
+ const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, step));
200
+ const readout = recapLines(rows, {
201
+ diff: diffRowValue(countDiff(work), false),
202
+ spent: spentRowValue(spentInputs(root, slug, step, check)),
203
+ constraints: parseConstraintCount(intent),
204
+ });
205
+ const parts = [];
206
+ if (summary !== null) {
207
+ parts.push(recordSummary(summary));
208
+ }
209
+ if (readout.length > 0) {
210
+ parts.push([readoutLabel(step, parseSteps(intent)), '', ...fence('text', readout)].join('\n'));
211
+ }
212
+ parts.push(verdictLine(ladder, worst));
213
+ return [...withRecommendation(parts, recommendation), ...sectionBlocks(sections)].join('\n\n');
214
+ }
215
+ /**
216
+ * The plan's record for the Log: the cold read's recommendation and the
217
+ * findings behind it. Nothing is measured at the plan pause, so no Readout and
218
+ * no Verdict ride here. Null when the detail file carries neither.
219
+ */
220
+ export function planRecord(detail) {
221
+ const recommendation = parseRecommendation(detail);
222
+ const sections = parseSections(detail);
223
+ if (recommendation === null && sections.length === 0) {
224
+ return null;
47
225
  }
48
- const block = inFlight !== null ? pauseBlock(current, titleOf(steps, current), nextUp) : boundaryBlock(current, titleOf(steps, current), nextUp);
49
- process.stdout.write(`${block}\n`);
226
+ return [...withRecommendation([], recommendation), ...sectionBlocks(sections)].join('\n\n');
227
+ }
228
+ /**
229
+ * The record's opening block: the Summary lead without the `(details: …)`
230
+ * bracket, since the record is where the detail now lives, then the
231
+ * highlights under their own handles.
232
+ */
233
+ function recordSummary(summary) {
234
+ const lead = `**Summary**: ${summary.lead}`;
235
+ if (summary.highlights.length === 0) {
236
+ return lead;
237
+ }
238
+ return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
239
+ }
240
+ /**
241
+ * The full story behind each highlight, one block per section: the handle in
242
+ * bold, the title, then the body as the model wrote it. A section with no body
243
+ * is just its line.
244
+ */
245
+ function sectionBlocks(sections) {
246
+ return sections.map((s) => (s.body.length === 0 ? `**${s.n}.** ${s.title}` : `**${s.n}.** ${s.title}\n\n${s.body}`));
247
+ }
248
+ /**
249
+ * The driver turn's pointer: back at the step still open, at the spike raised
250
+ * over it, or forward from the boundary when the step has already exited.
251
+ *
252
+ * A park or a spike interrupts a step without ending it, so nothing landed and
253
+ * no Verdict rides above this line.
254
+ */
255
+ export function driverPointer(root, slug) {
256
+ const intent = readOr(intentPath(root, slug));
257
+ const steps = parseSteps(intent);
258
+ const inFlight = readMarker(stepPath(root, slug));
259
+ const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight, steps.length);
260
+ // The forward fallback skips the step just landed as well as the one open, so
261
+ // a checkpoint whose `[x]` flip failed still points on rather than back at
262
+ // work already recorded.
263
+ const landed = parseLastCheckpoint(readOr(checkpointsPath(root, slug)))?.n ?? null;
264
+ const forward = steps.find((s) => !s.done && s.n !== (inFlight ?? landed));
265
+ return driver ?? nextUpLine(forward, steps.length, relative(root, intentPath(root, slug)));
266
+ }
267
+ /**
268
+ * The Verdict for a step that just landed: the assembled rows folded worst-of
269
+ * with the step's accrued stats, rendered as the line.
270
+ */
271
+ function landedVerdict(root, slug, intent, step) {
272
+ const { rows } = measured(root, slug, intent, readOr(detailPath(root)), step);
273
+ const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, step));
274
+ return verdictLine(ladder, worst);
275
+ }
276
+ /**
277
+ * Assemble the rows once, so the fence and the fold read the same set: the
278
+ * model's attested rows, with the check and seam rows replaced by the CLI's own
279
+ * measurements where one exists (measured beats attested).
280
+ *
281
+ * `checkGreen` reports the *measured* check alone. A move offered on an
282
+ * attested green would be a move the gate then refuses.
283
+ */
284
+ function measured(root, slug, intent, detail, step) {
285
+ const summary = readCheckSummary(root);
286
+ const measuredCheck = summary === null ? null : summaryCheckRow(summary);
287
+ const work = diffNumstat(root).filter((e) => !isArtifactPath(e.path));
288
+ const rows = { ...(parseRecap(detail)?.rows ?? {}) };
289
+ if (measuredCheck !== null) {
290
+ rows.check = measuredCheck;
291
+ }
292
+ const measuredSeam = seamRowFromDiff(work.map((e) => e.path), seamTokens(root, slug, intent, step));
293
+ if (measuredSeam !== null) {
294
+ rows.seam = measuredSeam;
295
+ }
296
+ return { rows, work, summary, checkGreen: measuredCheck?.verdict === 'true' };
297
+ }
298
+ /**
299
+ * Write an ending to stdout and return handoff's exit code.
300
+ *
301
+ * The parts are stacked by the same assembly a transition verb prints its own
302
+ * ending through, so the block reads the same whichever command emitted it.
303
+ */
304
+ function emit(parts) {
305
+ process.stdout.write(blocks(parts));
50
306
  return 0;
51
307
  }
52
308
  /**
53
- * The pause block: the step is built and awaiting approval.
309
+ * The Verdict line from an already-folded result: the ladder rung, and the
310
+ * worst component in a trailing parenthetical when one exists.
54
311
  *
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.
312
+ * No step segment rides here. The identity renders once per turn: the Readout
313
+ * label names the step, Next Up carries the progress count, and the Verdict is
314
+ * left saying the one thing only it says.
57
315
  */
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');
316
+ function verdictLine(ladder, worst) {
317
+ const state = `**Verdict**: ${ladder.glyph} ${ladder.state}`;
318
+ return worst === null ? state : `${state} (${worst})`;
319
+ }
320
+ /**
321
+ * The turn's opening block: the model's lead behind the Summary label, with the
322
+ * `(details: …)` bracket appended, then the numbered highlights beneath it.
323
+ *
324
+ * The lead and the titles are the markdown the model wrote, passed through; the
325
+ * label, the bracket, and the numbering are the CLI's, so the model never types
326
+ * a path and the block cannot drift from where the detail actually is. Empty
327
+ * when the detail file carries no lead: a Summary that says nothing vanishes
328
+ * rather than labeling nothing.
329
+ */
330
+ function summaryBlock(summary, where) {
331
+ if (summary === null) {
332
+ return '';
333
+ }
334
+ const lead = `**Summary**: ${summary.lead} (details: \`${where}\`)`;
335
+ if (summary.highlights.length === 0) {
336
+ return lead;
337
+ }
338
+ return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
66
339
  }
67
340
  /**
68
- * The boundary block: the checkpoint has landed and the loop is back at the
69
- * step boundary, pointing at what to build next.
341
+ * The Readout's label: the step the fence below it measures, named once.
70
342
  */
71
- function boundaryBlock(step, title, nextUp) {
72
- return [`Step ${step}${paren(title)} checkpointed — back at the boundary.`, '', nextUpLine(nextUp)].join('\n');
343
+ function readoutLabel(step, steps) {
344
+ const open = steps.find((s) => s.n === step);
345
+ const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
346
+ return `**Readout**: Step ${step}${title}`;
73
347
  }
74
348
  /**
75
- * The forward pointer, shared by both blocks: the next undone step, its title,
76
- * and its advisory `- model:` recommendation.
349
+ * What the `spent` row is rendered from: the step's own stamps and counters
350
+ * from stats.json, the turns the ledger counted across the step (TURN minus
351
+ * the TICK stamped on entry), and the last gate's wall clock.
77
352
  *
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.
353
+ * A host with no turn hook grows no ledger, so `turns` comes back null there
354
+ * and the clause simply vanishes rather than reading zero.
81
355
  */
82
- function nextUpLine(nextUp) {
83
- if (nextUp === undefined) {
84
- return 'No planned steps remain — /plumbbob:step to add an increment, or /plumbbob:finish.';
356
+ function spentInputs(root, slug, step, summary) {
357
+ const stats = readStats(root, slug)[String(step)] ?? {};
358
+ const turn = readTurn(root);
359
+ const tick = readMarker(tickPath(root, slug));
360
+ return {
361
+ startedAt: stats.startedAt,
362
+ landedAt: stats.landedAt,
363
+ now: Date.now(),
364
+ turns: turn === null || tick === null ? null : Math.max(0, turn - tick),
365
+ redChecks: stats.redChecks ?? 0,
366
+ gateMs: summary?.total_duration_ms ?? null,
367
+ driftWarnings: stats.driftWarnings ?? 0,
368
+ };
369
+ }
370
+ /**
371
+ * The seam tokens governing `step`: the SEAM marker `build` wrote is
372
+ * authoritative while a build is live, else the step's declared seam parsed
373
+ * from intent.md. Empty when neither resolves, which vanishes the measured
374
+ * seam row rather than flagging the whole tree.
375
+ */
376
+ function seamTokens(root, slug, intent, step) {
377
+ try {
378
+ const fromFile = readFileSync(seamPath(root, slug), 'utf8')
379
+ .split('\n')
380
+ .map((l) => l.trim())
381
+ .filter((l) => l.length > 0);
382
+ if (fromFile.length > 0) {
383
+ return fromFile;
384
+ }
85
385
  }
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.`;
386
+ catch {
387
+ // no SEAM marker: fall through to the declared seam.
388
+ }
389
+ const parsed = parseStepSeam(intent, step);
390
+ return parsed.ok ? parsed.seam : [];
391
+ }
392
+ /**
393
+ * Wrap lines in a markdown fence whose rail outruns any backtick run inside,
394
+ * so a patch that itself contains fence markers cannot break out of the
395
+ * `diff` fence it rides in.
396
+ */
397
+ function fence(lang, lines) {
398
+ const longest = lines.reduce((max, l) => Math.max(max, ...(l.match(/`+/g) ?? ['']).map((run) => run.length)), 0);
399
+ const rail = '`'.repeat(Math.max(3, longest + 1));
400
+ return [`${rail}${lang}`, ...lines, rail];
401
+ }
402
+ // The recommendation's label (docs/presentation.md): the one bold token the
403
+ // turn body spends, prepended here so the model never types it.
404
+ const RECOMMENDATION_LABEL = '**Recommendation**: ';
405
+ /**
406
+ * Append the recommendation beneath a block, blank-line separated, when the
407
+ * model wrote one; the ending's last text is then the model's own call. The
408
+ * label is the CLI's: it announces what the last text is before the eye
409
+ * reads it, and a label the model typed would be one more line to drift.
410
+ */
411
+ function withRecommendation(parts, recommendation) {
412
+ return recommendation === null ? [...parts] : [...parts, `${RECOMMENDATION_LABEL}${recommendation}`];
89
413
  }
90
414
  /**
91
- * A step's title in parentheses, or nothing when the title is empty.
415
+ * The step's accrued stats (seam strays, red checks, reverts) plus commits that
416
+ * landed since the last ledger entry outside plumbbob's checkpoints: the
417
+ * advisory inputs to the Verdict's third fold rung.
92
418
  */
93
- function paren(title) {
94
- return title !== null && title.length > 0 ? ` (${title})` : '';
419
+ function accruedStats(root, slug, step) {
420
+ const stats = readStats(root, slug)[String(step)] ?? {};
421
+ const anchor = lastLedgerSha(readOr(checkpointsPath(root, slug)));
422
+ return {
423
+ driftWarnings: stats.driftWarnings ?? 0,
424
+ redChecks: stats.redChecks ?? 0,
425
+ reverts: stats.reverts ?? 0,
426
+ outOfBand: anchor === null ? 0 : commitsSince(root, anchor, PLAN_COMMIT_MARKER),
427
+ };
428
+ }
429
+ /**
430
+ * The last `plumbbob check` run's `.check/summary.json`, or null when it is
431
+ * absent or unreadable (nothing to measure with; the fold then falls back to
432
+ * the readout's attested row, if the model wrote one).
433
+ *
434
+ * Read once: the check row is measured from it, and so is the gate's wall
435
+ * clock in the `spent` row.
436
+ */
437
+ function readCheckSummary(root) {
438
+ try {
439
+ return JSON.parse(readFileSync(join(root, '.check', 'summary.json'), 'utf8'));
440
+ }
441
+ catch {
442
+ return null;
443
+ }
95
444
  }
96
445
  /**
97
- * The title of step `n` from the parsed steps, or null when the step is
98
- * unknown or untitled.
446
+ * The Your Call block: the moves a human actually makes at a pause, each
447
+ * quoting what they say and stating what happens next.
448
+ *
449
+ * Nobody types "needs work", so the block names what really happens instead:
450
+ * a message that asks is an expand (nothing changes), and a message that
451
+ * directs is the fix. `revert` comes last, because a destructive move is named
452
+ * rather than discovered. `looks good` renders only while the measured check is
453
+ * green; offering a move that would refuse teaches a false ceremony.
99
454
  */
100
- function titleOf(steps, n) {
101
- return steps.find((s) => s.n === n)?.title ?? null;
455
+ function yourCallBlock(step, checkGreen) {
456
+ const moves = [];
457
+ if (checkGreen) {
458
+ moves.push(callLine('`looks good`', `I checkpoint Step ${step}; back to the boundary`));
459
+ }
460
+ moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes'));
461
+ moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
462
+ moves.push(callLine('`revert`', 'I wind the work back to the last checkpoint'));
463
+ return ['**Your Call**:', '', ...moves].join('\n');
464
+ }
465
+ /**
466
+ * One Your Call row: a list item naming the move, then the outcome clause
467
+ * behind the arrow, opening with a capital letter. The move stays lowercase
468
+ * because it quotes what the human says.
469
+ */
470
+ function callLine(move, outcome) {
471
+ return `- ${move} → ${outcome}`;
472
+ }
473
+ /**
474
+ * The plan pause's Your Call block: the same shape with the moves that apply
475
+ * there. Nothing is recorded yet, so `revert` has nothing to wind back to and
476
+ * vanishes; `looks good` names the step the plan starts at, which is Step 1 at
477
+ * the plan pause and the first undone step after a mid-build refine.
478
+ */
479
+ function planCallBlock(first) {
480
+ const starts = first === undefined ? '' : `; /plumbbob:build starts Step ${first.n}`;
481
+ return [
482
+ '**Your Call**:',
483
+ '',
484
+ callLine('`looks good`', `I mark the plan decided${starts}`),
485
+ callLine('`expand`, or any question', 'I show more of what is there; nothing changes'),
486
+ callLine('anything that reads as direction', 'I take it as what to sharpen; the plan is cheap to change now'),
487
+ ].join('\n');
488
+ }
489
+ /**
490
+ * The driver turn's pointer: back to the step still in flight, since a park or
491
+ * a spike interrupts a step without ending it. No model clause rides here; the
492
+ * step is already being built, so the `/model` call is behind us. Null when no
493
+ * step is open, which is the caller's cue to fall back to the forward pointer.
494
+ */
495
+ function driverNextUpLine(steps, inFlight, total) {
496
+ if (inFlight === null) {
497
+ return null;
498
+ }
499
+ const open = steps.find((s) => s.n === inFlight);
500
+ const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
501
+ // The progress count rides every tier's pointer, but only where the plan
502
+ // actually holds the step: "step 9 of 3" would be worse than no count.
503
+ const progress = open === undefined ? '' : ` of ${total}`;
504
+ return `**Next Up**: Back to Step ${inFlight}${progress}${title}`;
505
+ }
506
+ /**
507
+ * The driver pointer while a spike is open: the spike outranks the step it
508
+ * interrupted, so the move named is closing it and the step to come back to
509
+ * rides as a trailing clause.
510
+ *
511
+ * No progress count rides that clause. The count belongs to the step a pointer
512
+ * aims at, and this one aims at the spike; with nothing in flight (a spike
513
+ * opened at the boundary) the clause vanishes and the move stands alone.
514
+ */
515
+ function spikeNextUpLine(inFlight) {
516
+ const back = inFlight === null ? '' : `, then back to Step ${inFlight}`;
517
+ return `**Next Up**: Close the spike - /plumbbob:spike done${back}`;
518
+ }
519
+ /**
520
+ * The forward pointer: the next undone step with the progress count, its
521
+ * title, and a closing bracket carrying its advisory `- model:` recommendation
522
+ * and where to read the step in full. No next step ⇒ the finish/step nudge
523
+ * instead.
524
+ *
525
+ * The model is the second bold token the line spends, because it is the one
526
+ * the human acts on (a `/model` call) before the next run; the path is a bare
527
+ * `path:line` in a code span, the one link form that opens in a host and still
528
+ * reads as a path in a PR diff.
529
+ */
530
+ function nextUpLine(nextUp, total, where) {
531
+ if (nextUp === undefined) {
532
+ return '**Next Up**: Nothing planned - /plumbbob:step or /plumbbob:finish';
533
+ }
534
+ const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
535
+ const token = modelToken(nextUp.model);
536
+ const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
537
+ bracket.push(`details: \`${where}:${nextUp.line}\``);
538
+ return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
102
539
  }
103
540
  /**
104
541
  * 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.
542
+ * `model: opus` line followed by an em dash and rationale yields `opus`.
107
543
  *
108
544
  * Null when there is no recommendation, or it degraded to whitespace (folding
109
545
  * the null model into the same guard keeps both branches live).
@@ -112,6 +548,13 @@ function modelToken(model) {
112
548
  const first = model?.trim().split(/\s+/)[0];
113
549
  return first !== undefined && first.length > 0 ? first : null;
114
550
  }
551
+ /**
552
+ * Capitalize a token's first letter for the card's title-case furniture
553
+ * (`(model: Sonnet)`); the rest rides through unchanged.
554
+ */
555
+ function capitalize(s) {
556
+ return s.length === 0 ? s : `${s[0]?.toUpperCase()}${s.slice(1)}`;
557
+ }
115
558
  /**
116
559
  * Read a file as UTF-8, or return '' when it is missing or unreadable: an
117
560
  * absent sidecar file reads as empty rather than throwing.
@@ -125,11 +568,11 @@ function readOr(path) {
125
568
  }
126
569
  }
127
570
  /**
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.
571
+ * The count in a one-line marker file: STEP (which step is open) and TICK (the
572
+ * turn the step was entered on) are both written this way. Null when the file
573
+ * is absent or holds anything but a bare number.
131
574
  */
132
- function readStep(path) {
575
+ function readMarker(path) {
133
576
  try {
134
577
  const raw = readFileSync(path, 'utf8').trim();
135
578
  return /^\d+$/.test(raw) ? Number(raw) : null;