plumbbob 0.13.4 → 0.13.6

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,54 +1,56 @@
1
1
  // `plumbbob handoff`: emit the CLI-owned ending of a turn as one contiguous
2
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
6
- // the readout fence is a bold label, a colon, and text that wraps, one blank
7
- // line between blocks and no fence but the readout's own. It derives the moment
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
12
- // with none in flight ⇒ the orientation-tier ending (Verdict and Next Up); a
13
- // fresh session with nothing yet measured ⇒ the forward pointer alone.
3
+ // leaves the detail file with a blank line around each heading, it stamps
4
+ // which card has shown in `PAUSE` (or `STOP`, for a stop card), and every
5
+ // render it prints is recorded in `RELAY` for the check at the turn's end. It
6
+ // derives the moment from the session: a step in flight ⇒ the decision-tier
7
+ // card (the Summary, the labeled Readout and its fence, the inline diff when
8
+ // the change is 20 lines or fewer, Your Call, the state table, the
9
+ // Recommendation, and the `more details` link last); a landed step with none
10
+ // in flight ⇒ the orientation-tier ending (the state table, headed by the
11
+ // Verdict); a fresh session with nothing yet measured ⇒ the table's pointer
12
+ // alone.
14
13
  //
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`.
14
+ // A card shows once (D110 (reply-after-the-card)). After a pause, a stop, or
15
+ // a boundary has shown, every later render of it is the one line that points
16
+ // back at it, whatever the work did since: the turn that asked is the model's
17
+ // reply, closed by that line, and the card comes back only on `--full`.
18
18
  //
19
19
  // The whole decision turn is rendered here, its opening block included: the
20
- // Summary lead and the numbered highlights come out of `.plumbbob/detail.md`
21
- // as the markdown the model wrote, with the `(details: …)` bracket appended so
22
- // the model never types a path. A region the model does not author is a region
23
- // it cannot narrate into.
20
+ // Summary lead comes out of `.plumbbob/detail.md` as the markdown the model
21
+ // wrote, and the card closes on one `more details` link to the file, so the
22
+ // model never types a path (D113 (short-card)). A region the model does not
23
+ // author is a region it cannot narrate into.
24
24
  //
25
25
  // If the CLI can compute a recap row, the CLI does: the check row comes from
26
26
  // the last run's summary, the seam and diff rows from the SEAM marker against
27
27
  // the step's whole product (everything changed since HEAD, staged or not, plus
28
- // each non-ignored new file). The model's part (`.plumbbob/detail.md`) supplies
29
- // only what takes judgment: the Summary, the three judgment rows, and the
30
- // recommendation; the Verdict folds the same assembled rows worst-of with the
31
- // step's accrued stats, so the fence shows exactly what the fold saw.
28
+ // each non-ignored new file), with the changed files listed beneath the diff
29
+ // row. The model's part (`.plumbbob/detail.md`) supplies only what takes
30
+ // judgment: the Summary, the three judgment rows, and the recommendation; the
31
+ // Verdict folds the same assembled rows worst-of with the step's accrued
32
+ // stats, so the fence shows exactly what the fold saw.
32
33
  //
33
34
  // Every tier's ending is emitted here, so no skill has to fake the furniture in
34
35
  // prose: `--plan` renders the plan-pause ending and `--driver` the driver
35
36
  // turn's pointer, the two endings the session state cannot tell apart from the
36
- // ones above, and `--stop` the stop card that closes a run's range, which reads
37
- // a boundary the session state cannot tell from a plain one either. The stop
38
- // card renders once too, under its own stamp in `STOP`, which also carries the
39
- // turn it printed in for `plumbbob harvest`'s latch. The driver pointer reads the phase it lands in: a spike open over
40
- // the step names closing the spike, a step exit (a revert, an abandon, a spike
41
- // closed at the boundary) finds nothing in flight and points forward, with no
42
- // Verdict either way, since nothing landed.
43
- import { createHash } from 'node:crypto';
37
+ // ones above; `--stop` the stop card that closes a run's range, which reads
38
+ // a boundary the session state cannot tell from a plain one either; and
39
+ // `--harvest` a boundary harvest's leans, as the same table the stop card
40
+ // carries (D112 (lean-table)). The stop card shows once too, under its own
41
+ // stamp in `STOP`, which also carries the turn it printed in for
42
+ // `plumbbob harvest`'s latch. The driver pointer reads the phase it lands in:
43
+ // a spike open over the step names closing the spike, a step exit (a revert,
44
+ // an abandon, a spike closed at the boundary) finds nothing in flight and
45
+ // points forward, with no Verdict either way, since nothing landed.
44
46
  import { readFileSync, writeFileSync } from 'node:fs';
45
47
  import { basename, join, relative } from 'node:path';
46
48
  import { PLAN_COMMIT_MARKER, commitNumstat, commitsBetween, commitsSince, diffNumstat, diffPatch, findRepoRoot, } from "../lib/git.js";
47
- import { blocks, notice, printEnding, transition } from "../lib/notice.js";
49
+ import { blocks, cell, notice, printEnding, stateTable } from "../lib/notice.js";
48
50
  import { hasStep } from "../lib/steplist.js";
49
51
  import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
50
- import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, pausePath, readStats, readStop, readTurn, resolveBuild, seamPath, stepPath, tickPath, writeStop, } from "../lib/sidecar.js";
51
- import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, measureRange, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeLanded, rangeSpan, rangeStanding, recapLines, remainingOrder, resolveRun, runBracket, seamRowFromDiff, spentRowValue, stopFollowUp, summaryCheckRow, } from "../lib/orient.js";
52
+ import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, readPause, readStats, readStop, readTurn, resolveBuild, seamPath, stepPath, tickPath, writePause, writeStop, } from "../lib/sidecar.js";
53
+ import { buildOrderPhrase, countDiff, diffFileItems, diffRowValue, foldVerdict, lastLedgerSha, measureRange, normalizeDetailHeadings, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOpenQuestions, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeLanded, rangeSpan, rangeStanding, recapLines, remainingOrder, resolveRun, runBracket, seamRowFromDiff, spentRowValue, stopFollowUp, summaryCheckRow, } from "../lib/orient.js";
52
54
  // The inline-diff threshold (docs/presentation.md): at 20 changed lines or
53
55
  // fewer the patch rides the turn; at 21 it stays in the working tree behind
54
56
  // the counted diff row.
@@ -66,19 +68,21 @@ const SEAM_RULE = '\n---';
66
68
  *
67
69
  * Requires an active session (the STATE sentinel under `.plumbbob/`). The step
68
70
  * tiers are derived, not passed: a step in flight yields the full
69
- * decision-tier ending (the Now line, the Readout and its fence, the inline
70
- * diff when small enough, the Verdict, Next Up, Your Call, and the
71
- * Recommendation), and so does
72
- * a named step that has not landed when nothing is in flight (a hand-built diff
73
- * under verify). That card renders once: a later render of the same pause, with
74
- * nothing in it changed, is the one-line reminder, and `--full` prints the card
75
- * whatever the stamp says. A landed step yields the orientation-tier ending
76
- * (Verdict and Next Up, no Your Call),
77
- * and a fresh session with nothing to report yields only
78
- * the forward pointer. The two endings no session state can distinguish are
79
- * named by a flag: `--plan` is the plan pause (a decision turn about the plan,
80
- * not a diff) and `--driver` is a driver turn (a park, a spike, a `use`), which
81
- * interrupts a step without ending it.
71
+ * decision-tier card (the Summary, the Readout and its fence, the inline diff
72
+ * when small enough, Your Call, the state table, the Recommendation, and the
73
+ * `more details` link), and so does a named step that has not landed when
74
+ * nothing is in flight (a hand-built diff under verify). A landed step yields
75
+ * the orientation-tier ending (the state table, no Your Call), and a fresh
76
+ * session with nothing to report yields the table's pointer alone.
77
+ *
78
+ * Each card shows once (D110 (reply-after-the-card)): once a pause or a
79
+ * boundary has shown, a later render of it is the one line that points back
80
+ * at it, whatever the work did since, and `--full` prints the card again. The
81
+ * endings no session state can distinguish are named by a flag: `--plan` is
82
+ * the plan pause (a decision turn about the plan, not a diff), `--driver` is a
83
+ * driver turn (a park, a spike, a `use`), which interrupts a step without
84
+ * ending it, `--stop` is the stop card, and `--harvest` a boundary harvest's
85
+ * leans.
82
86
  */
83
87
  export function handoff(cwd, args = []) {
84
88
  const root = findRepoRoot(cwd);
@@ -87,7 +91,7 @@ export function handoff(cwd, args = []) {
87
91
  return 1;
88
92
  }
89
93
  const { build: slug, rest } = resolveBuild(root, args);
90
- const tiers = ['--plan', '--driver', '--stop'].filter((flag) => rest.includes(flag));
94
+ const tiers = ['--plan', '--driver', '--stop', '--harvest'].filter((flag) => rest.includes(flag));
91
95
  if (tiers.length > 1) {
92
96
  process.stderr.write(`plumbbob: handoff: ${tiers.join(' and ')} name different tiers; pass one.\n`);
93
97
  return 1;
@@ -95,8 +99,12 @@ export function handoff(cwd, args = []) {
95
99
  // A build named by flag rides any command that reprints this ending, since
96
100
  // the cursor may point elsewhere.
97
101
  const named = args.includes('--build') ? slug : null;
102
+ const full = rest.includes('--full');
98
103
  if (rest.includes('--stop')) {
99
- return stopCard(root, slug, named, rest.includes('--full'));
104
+ return stopCard(root, slug, named, full);
105
+ }
106
+ if (rest.includes('--harvest')) {
107
+ return harvestCard(root, slug);
100
108
  }
101
109
  const intent = readOr(intentPath(root, slug));
102
110
  // In build order, so the pointer names the step a bare `plumbbob build`
@@ -108,19 +116,20 @@ export function handoff(cwd, args = []) {
108
116
  // Where a pointer sends the reader to read the step itself: the intent file
109
117
  // as the human would type it, relative to the repo root.
110
118
  const where = relative(root, intentPath(root, slug));
119
+ const detailWhere = relative(root, detailPath(root));
111
120
  if (rest.includes('--plan')) {
112
121
  // The plan pause judges the plan, so nothing is measured and no Verdict
113
- // renders; the pointer and the moves both aim at the first undone step. A
122
+ // renders; the moves and the table both aim at the first undone step. A
114
123
  // decision turn still ends on the model's recommendation when it wrote one.
115
124
  const first = steps.find((s) => !s.done);
116
125
  const detail = settleDetail(root, rawDetail);
117
126
  const plan = [
118
127
  SEAM_RULE,
119
- nextUpBlock(intent, buildLog, first, null, where),
120
128
  planCallBlock(first, cardRun(intent, null)),
129
+ stateTable(stateRows({ intent, buildLog, verdict: null, now: null, stop: null, nextUp: first, exclude: null, where })),
121
130
  ];
122
131
  const command = reprintCommand('--plan', undefined, named);
123
- return emit(root, { tier: 'decision', render: 'plan', command }, withRecommendation(plan, parseRecommendation(detail)));
132
+ return emit(root, { tier: 'decision', render: 'plan', command }, cardFoot(plan, detail, detailWhere));
124
133
  }
125
134
  const explicit = rest.find((a) => /^\d+$/.test(a));
126
135
  const lastDone = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
@@ -143,9 +152,9 @@ export function handoff(cwd, args = []) {
143
152
  return emit(root, { tier: 'driver', render: 'driver' }, [driverPointer(root, slug)]);
144
153
  }
145
154
  if (current === null) {
146
- // Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
155
+ // Nothing measured yet (a fresh session): no Verdict, just the table's pointer.
147
156
  return emit(root, { tier: 'orientation', render: 'pointer' }, [
148
- nextUpBlock(intent, buildLog, nextUp, current, where),
157
+ stateTable(stateRows({ intent, buildLog, verdict: null, now: null, stop: null, nextUp, exclude: current, where })),
149
158
  ]);
150
159
  }
151
160
  // A step is pending, and its hand-off is the pause, while it is in flight.
@@ -154,61 +163,73 @@ export function handoff(cwd, args = []) {
154
163
  // `/plumbbob:verify` with no STEP marker, and the number is how the skill says
155
164
  // which pause to render.
156
165
  const pending = inFlight !== null || (explicit !== undefined && steps.some((s) => s.n === current && !s.done));
166
+ const shown = readPause(root, slug);
157
167
  if (!pending) {
158
- // The boundary: the orientation-tier ending, the state word, the Stop part
159
- // where a range just landed, and the forward pointer, no decision pending
160
- // and so no Your Call. The same parts the checkpoint printed for itself,
161
- // from the same call.
162
- const landed = boundaryEnding(root, slug, current);
163
- return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.stop, landed.pointer]);
168
+ if (!full && shown?.kind === 'landed' && shown.step === current) {
169
+ // The boundary the human already read: the reply above this line is what
170
+ // they asked for, and the block would push it off the screen. Driver
171
+ // tier, since nothing is pending to hold the turn for.
172
+ return emit(root, { tier: 'driver', render: 'pointer' }, [pointerLine(intent, nextUp, current, where)]);
173
+ }
174
+ // The boundary: the orientation-tier ending, the state table headed by the
175
+ // Verdict, with the Stop row where a range just landed, and no decision
176
+ // pending, so no Your Call. The same table the checkpoint printed for
177
+ // itself, from the same call.
178
+ writePause(root, slug, { kind: 'landed', step: current });
179
+ return emit(root, { tier: 'orientation', render: 'boundary' }, [stateTable(boundaryEnding(root, slug, current).state)]);
164
180
  }
165
181
  // The pause: the whole turn as one contiguous block, each part a labeled line
166
- // with one blank line between, and the only fence the readout's own (plus the
167
- // inline diff when the change is small enough). It opens on the step in
168
- // flight and then the Summary the model wrote into the detail file, so there
169
- // is no seam left to rule off.
182
+ // or the state table, with one blank line between, and the only fence the
183
+ // readout's own (plus the inline diff when the change is small enough). It
184
+ // opens on the Summary the model wrote into the detail file, so there is no
185
+ // seam left to rule off.
170
186
  const detail = settleDetail(root, rawDetail);
187
+ if (!full && shown?.kind === 'pause' && shown.step === current) {
188
+ // The pause the human already read: the reply above this line is what
189
+ // they asked for, and the card would push it off the screen. It stays the
190
+ // reminder whatever changed since, a rewritten detail file, an edit, or a
191
+ // gate run that moved the check row, because a later turn at the pause is
192
+ // the model's reply, and the card comes back only when asked.
193
+ const command = reprintCommand(null, explicit, named);
194
+ return emit(root, { tier: 'decision', render: 'reminder', command }, [
195
+ `**Next Up**: Back to ${stepPhrase(current, steps)}`,
196
+ ]);
197
+ }
171
198
  const { rows, work, summary, checkGreen } = measured(root, slug, intent, detail, current);
172
199
  const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, current));
173
- const verdict = verdictLine(ladder, worst);
174
200
  const counts = countDiff(work);
175
201
  const changed = counts.added + counts.removed;
176
202
  // The patch comes first so `· inline below` is a promise the fence keeps: a
177
203
  // small change git can still print nothing for (a vanished path) falls back
178
- // to the bare counts rather than pointing at a fence that never arrives. The
179
- // stamp hashes it at any size, so it is read whenever there is a change.
180
- const fullPatch = changed > 0 ? diffPatch(root, work) : '';
181
- const patch = changed <= INLINE_DIFF_MAX ? fullPatch : '';
204
+ // to the bare counts rather than pointing at a fence that never arrives.
205
+ const patch = changed > 0 && changed <= INLINE_DIFF_MAX ? diffPatch(root, work) : '';
182
206
  const inline = patch.length > 0;
183
- const diffRow = diffRowValue(counts, inline);
184
- const stamp = pauseStamp(current, detail, measuredLines(rows, diffRow), fullPatch);
185
- if (!rest.includes('--full') && readOr(pausePath(root, slug)).trim() === stamp) {
186
- // The pause the human already read, unchanged: the answer above this line
187
- // is what they asked for, and the card would push it off the screen.
188
- const command = reprintCommand(null, explicit, named);
189
- return emit(root, { tier: 'decision', render: 'reminder', command }, [
190
- `**Next Up**: Back to ${stepPhrase(current, steps)}`,
191
- ]);
192
- }
193
- writePause(root, slug, stamp);
207
+ writePause(root, slug, { kind: 'pause', step: current });
194
208
  const readout = recapLines(rows, {
195
- diff: diffRow,
209
+ diff: diffRowValue(counts, inline),
210
+ files: diffFileItems(work),
196
211
  spent: spentRowValue(spentInputs(root, slug, current, summary)),
197
212
  constraints: parseConstraintCount(intent),
198
213
  });
199
- const parts = [
200
- `**Now**: ${stepPhrase(current, steps)}`,
201
- summaryBlock(parseSummary(detail), relative(root, detailPath(root))),
202
- ];
214
+ const parts = [summaryBlock(parseSummary(detail))];
203
215
  if (readout.length > 0) {
204
216
  parts.push([READOUT_LABEL, '', ...fence('text', readout)].join('\n'));
205
217
  }
206
218
  if (inline) {
207
219
  parts.push(fence('diff', patch.split('\n')).join('\n'));
208
220
  }
209
- parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen, cardRun(intent, current)));
221
+ parts.push(yourCallBlock(current, checkGreen, cardRun(intent, current)), stateTable(stateRows({
222
+ intent,
223
+ buildLog,
224
+ verdict: verdictValue(ladder, worst),
225
+ now: stepPhrase(current, steps),
226
+ stop: null,
227
+ nextUp,
228
+ exclude: current,
229
+ where,
230
+ })));
210
231
  const command = reprintCommand('--full', explicit, named);
211
- return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
232
+ return emit(root, { tier: 'decision', render: 'card', command }, cardFoot(parts, detail, detailWhere));
212
233
  }
213
234
  /**
214
235
  * The detail file's text with a blank line around each heading, written back to
@@ -232,60 +253,16 @@ function settleDetail(root, raw) {
232
253
  return clean;
233
254
  }
234
255
  /**
235
- * The stamp of a step pause: its number and a hash of everything on the card a
236
- * later turn could change, so the reminder prints only while the pause is the
237
- * one the human already read.
256
+ * The CLI-rendered state of a boundary ending: the state table's rows, headed
257
+ * by the Verdict folded from everything measurable right now, with the Stop
258
+ * row when the landing finished a range, then Next Up and the rows beneath it.
238
259
  *
239
- * Hashed: the detail file as normalized, the measured check and seam rows and
240
- * the diff row as rendered, and the patch the diff row counts, so an edit that
241
- * keeps the counts still reads as a touched file. Never `spent`: it counts
242
- * turns, so it moves on every turn and would make every render look new.
243
- */
244
- function pauseStamp(step, detail, rendered, patch) {
245
- const hash = createHash('sha256');
246
- for (const part of [detail, ...rendered, patch]) {
247
- hash.update(part).update('\0');
248
- }
249
- return `${step} ${hash.digest('hex')}`;
250
- }
251
- /**
252
- * The readout rows the CLI measured, as the card renders them: `check`,
253
- * `seam`, and `diff`. The judgment rows ride the detail file, which the stamp
254
- * hashes whole, and `spent` stays out of it.
255
- */
256
- function measuredLines(rows, diff) {
257
- const measuredRows = {};
258
- if (rows.check !== undefined) {
259
- measuredRows.check = rows.check;
260
- }
261
- if (rows.seam !== undefined) {
262
- measuredRows.seam = rows.seam;
263
- }
264
- return recapLines(measuredRows, { diff, spent: null, constraints: 0 });
265
- }
266
- /**
267
- * Record the stamp of the card just printed. A failed write costs one more
268
- * full card on a later turn, never this turn's output or its exit code.
269
- */
270
- function writePause(root, slug, stamp) {
271
- try {
272
- writeFileSync(pausePath(root, slug), `${stamp}\n`);
273
- }
274
- catch {
275
- // A read-only tree or a vanished build folder: the card still renders.
276
- }
277
- }
278
- /**
279
- * The CLI-rendered parts of a boundary ending: the Verdict folded from
280
- * everything measurable right now, the Stop part when the landing finished a
281
- * range, and the pointer at the step to come.
282
- *
283
- * `step` is the step that just landed, or null at a plan commit, where nothing
284
- * was measured and the Verdict and the Stop part both vanish. A transition verb
285
- * prints these beneath its own lead line, so a boundary reads identically
286
- * whether `checkpoint` emitted it or a later `handoff` did; the paths a stray
287
- * advisory named are the one part that does not survive, and the commit holds
288
- * those.
260
+ * `step` is the step that just landed, or null at a plan commit and a stop's
261
+ * record, where nothing was measured and the Verdict and the Stop row both
262
+ * vanish. A transition verb prints the table beneath its own lead line, so a
263
+ * boundary reads identically whether `checkpoint` emitted it or a later
264
+ * `handoff` did; the paths a stray advisory named are the one part that does
265
+ * not survive, and the commit holds those.
289
266
  */
290
267
  export function boundaryEnding(root, slug, step) {
291
268
  const intent = readOr(intentPath(root, slug));
@@ -294,10 +271,17 @@ export function boundaryEnding(root, slug, step) {
294
271
  const where = relative(root, intentPath(root, slug));
295
272
  const landed = landedRange(intent, steps, step);
296
273
  return {
297
- verdict: step === null ? null : landedVerdict(root, slug, intent, step),
298
- stop: landed === null ? null : stopLead(landed, where),
274
+ state: stateRows({
275
+ intent,
276
+ buildLog: readOr(buildLogPath(root, slug)),
277
+ verdict: step === null ? null : landedVerdict(root, slug, intent, step),
278
+ now: null,
279
+ stop: landed === null ? null : stopValue(landed, where),
280
+ nextUp,
281
+ exclude: step,
282
+ where,
283
+ }),
299
284
  range: landed === null ? null : { item: landed.range.item, count: landed.count },
300
- pointer: nextUpBlock(intent, readOr(buildLogPath(root, slug)), nextUp, step, where),
301
285
  };
302
286
  }
303
287
  /**
@@ -314,17 +298,13 @@ function landedRange(intent, steps, step) {
314
298
  return completed === null || completed.bare ? null : { range: completed, count, step };
315
299
  }
316
300
  /**
317
- * The Stop part: why the build stops at this boundary. One labeled line, the
318
- * range over the count, a plain hyphen, the stop as the plan wrote it, and a
319
- * bracket pointing at the item, where its note can be read. The stop card
320
- * opens on the same line, moved to the top of the card.
301
+ * The Stop row's fact: why the build stops at this boundary. The range over
302
+ * the count, a plain hyphen, the stop as the plan wrote it, and a bracket
303
+ * pointing at the item, where its note can be read. The stop card's state
304
+ * table carries the same row.
321
305
  */
322
- function stopLead(landed, where) {
323
- return transition({
324
- label: 'Stop',
325
- fact: `Range ${landed.range.item} of ${landed.count} landed - ${landed.range.stop}`,
326
- detail: [detailsLink(where, landed.range.line)],
327
- });
306
+ function stopValue(landed, where) {
307
+ return `Range ${landed.range.item} of ${landed.count} landed - ${landed.range.stop} (${detailsLink(where, landed.range.line)})`;
328
308
  }
329
309
  /**
330
310
  * The `details:` clause every bracket on a card carries, in its short linked
@@ -342,19 +322,20 @@ export function detailsLink(path, line) {
342
322
  return line === null ? `details: [${name}](${path})` : `details: [${name}:${line}](${path}#L${line})`;
343
323
  }
344
324
  /**
345
- * The stop card that closes a run's range (docs/presentation.md): the Stop
346
- * lead, the range's Summary and highlights from the range's detail file, the
347
- * Readout measured across the range, the Verdict, the Harvested part while a
348
- * park is open, Next Up, Your Call, and the recommendation.
325
+ * The stop card that closes a run's range (docs/presentation.md): the range's
326
+ * Summary from the range's detail file, the Readout measured across the range,
327
+ * the Harvested part while a park is open, Your Call, the state table headed
328
+ * by the Verdict, with the Stop row, Next Up, and the rows beneath it, the
329
+ * recommendation, and the `more details` link.
349
330
  *
350
331
  * It renders for the range the last landing completed, and refuses with a
351
332
  * step in flight, with no range just landed, and with a detail file written
352
333
  * for anything but that range, since a card with none of the model's review
353
- * on it would ask the human to approve nothing. It renders once per stop:
354
- * while the STOP stamp matches the range and what the card showed, a later
355
- * render is the one-line reminder, and `--full` prints the card whatever the
356
- * stamp says. Every card it prints rewrites the stamp with the turn it printed
357
- * in, which is the turn `plumbbob harvest` waits past.
334
+ * on it would ask the human to approve nothing. It shows once per stop: while
335
+ * the STOP stamp names the range, a later render is the one-line reminder,
336
+ * whatever changed since (a lean the human changed, a park, a gate run), and
337
+ * `--full` prints the card again. Every card it prints rewrites the stamp with
338
+ * the turn it printed in, which is the turn `plumbbob harvest` waits past.
358
339
  */
359
340
  function stopCard(root, slug, named, full) {
360
341
  const intent = readOr(intentPath(root, slug));
@@ -374,39 +355,38 @@ function stopCard(root, slug, named, full) {
374
355
  }));
375
356
  return 1;
376
357
  }
377
- const where = relative(root, intentPath(root, slug));
378
- const buildLog = readOr(buildLogPath(root, slug));
379
- const review = stopReview(root, slug, intent, range, detail);
380
- const parks = parkList(buildLog);
381
- const harvest = harvestPart(parks, harvestProposals(detail));
382
- const hash = stopHash(detail, review.stamped, harvest ?? '');
383
358
  const stamp = readStop(root, slug);
384
- if (!full && stamp !== null && stamp.range === range.item && stamp.hash === hash) {
385
- // The stop the human already read, unchanged: the answer above this line
386
- // is what they asked for, and the card would push it off the screen.
359
+ if (!full && stamp !== null && stamp.range === range.item) {
360
+ // The stop the human already read: the reply above this line is what they
361
+ // asked for, and the card would push it off the screen.
387
362
  const command = reprintCommand('--stop', undefined, named);
388
363
  return emit(root, { tier: 'decision', render: 'stop-reminder', command }, [
389
364
  `**Next Up**: Back to Range ${range.item} of ${count}'s stop - ${range.stop}`,
390
365
  ]);
391
366
  }
392
- writeStop(root, slug, { range: range.item, turn: readTurn(root), hash });
367
+ writeStop(root, slug, { range: range.item, turn: readTurn(root) });
368
+ const where = relative(root, intentPath(root, slug));
369
+ const review = stopReview(root, slug, intent, range, detail);
370
+ const parks = parkList(readOr(buildLogPath(root, slug)));
371
+ const proposals = harvestProposals(detail);
393
372
  const nextUp = steps.find((s) => !s.done && s.n !== step);
394
- const parts = [
395
- stopLead(landed, where),
396
- summaryBlock(parseSummary(detail), relative(root, detailPath(root))),
397
- ];
373
+ const parts = [summaryBlock(parseSummary(detail))];
398
374
  if (review.readout.length > 0) {
399
375
  parts.push([READOUT_LABEL, '', ...fence('text', review.readout)].join('\n'));
400
376
  }
401
- parts.push(review.verdict);
402
- if (harvest !== null) {
403
- parts.push(harvest);
404
- }
405
- parts.push(
406
- // The note carries no parks segment here: the Harvested lead above sizes the list.
407
- nextUpBlock(intent, '', nextUp, step, where), stopCallBlock(leanExample(parks, harvestProposals(detail)), intent));
377
+ parts.push(harvestPart(parks, proposals) ?? '', stopCallBlock(leanExample(parks, proposals), intent), stateTable(stateRows({
378
+ intent,
379
+ // No Parked row here: the Harvested lead above sizes the list.
380
+ buildLog: '',
381
+ verdict: review.verdict,
382
+ now: null,
383
+ stop: stopValue(landed, where),
384
+ nextUp,
385
+ exclude: step,
386
+ where,
387
+ })));
408
388
  const command = reprintCommand('--stop --full', undefined, named);
409
- return emit(root, { tier: 'decision', render: 'stop', command }, withRecommendation(parts, parseRecommendation(detail)));
389
+ return emit(root, { tier: 'decision', render: 'stop', command }, cardFoot(parts, detail, relative(root, detailPath(root))));
410
390
  }
411
391
  /**
412
392
  * The range the stop card reviews: the one the last landing completed, or the
@@ -450,11 +430,11 @@ function stopReview(root, slug, intent, range, detail) {
450
430
  readout: recapLines(rows, {
451
431
  steps: measure.steps,
452
432
  diff: measure.diff,
433
+ files: measure.files,
453
434
  spent: measure.spent,
454
435
  constraints: parseConstraintCount(intent),
455
436
  }),
456
- verdict: verdictLine(ladder, worst),
457
- stamped: recapLines(measure.rows, { steps: measure.steps, diff: measure.diff, spent: null, constraints: 0 }),
437
+ verdict: verdictValue(ladder, worst),
458
438
  };
459
439
  }
460
440
  /**
@@ -484,18 +464,6 @@ function rangeMeasure(root, slug, intent, range) {
484
464
  summary: readCheckSummary(root),
485
465
  });
486
466
  }
487
- /**
488
- * The stamp's hash of a stop card: the range's detail file, the measured rows
489
- * as rendered, and the Harvested part, which reads the park list. Never `spent`,
490
- * as at a pause.
491
- */
492
- function stopHash(detail, measured, harvest) {
493
- const hash = createHash('sha256');
494
- for (const part of [detail, ...measured, harvest]) {
495
- hash.update(part).update('\0');
496
- }
497
- return hash.digest('hex');
498
- }
499
467
  /**
500
468
  * The park list under `build-log.md`'s `## Park list`, each line with its
501
469
  * handle: its place in the list, counting every line ever parked, harvested
@@ -519,15 +487,23 @@ export function parkList(buildLog) {
519
487
  return m === null ? [] : [{ open: m[1] === ' ', text: (m[2] ?? '').trim() }];
520
488
  }).map((park, i) => ({ handle: i + 1, ...park }));
521
489
  }
490
+ // A proposal's first line: `- P6 → tangent: <the reason>`, or the `- park 6 →`
491
+ // form a detail written before D105 (park-handles) carried.
492
+ const PROPOSAL = /^-\s+(?:P|park\s+)(\d+)\s*(?:→|->)\s*([\w-]+)\s*(?::\s*(.*))?$/i;
493
+ // A proposal's indented field line: `- title: <2 to 5 words>` or `- why: <one clause>`.
494
+ const PROPOSAL_FIELD = /^-\s+(title|why)\s*:\s*(.*)$/i;
522
495
  /**
523
496
  * The proposals under a detail file's `## Harvest` section, one per
524
- * `- P6 → tangent: <the reason>` line, in the order written. The handle is the
525
- * one the card prints (D105 (park-handles)); the `- park 6 →` form a detail
526
- * written before it carried is still read.
497
+ * `- P6 → tangent: <the reason>` line, in the order written, each with the
498
+ * `- title:` and `- why:` lines indented beneath it. The handle is the one the
499
+ * card prints (D105 (park-handles)); the `- park 6 →` form a detail written
500
+ * before it carried is still read, and so is a proposal with no field lines,
501
+ * the shape every detail had before the lean table (D112 (lean-table)).
527
502
  *
528
503
  * The class is read as written, whatever its word: the card shows the
529
504
  * proposal the model made, and `plumbbob harvest` is what refuses one outside
530
- * blocker, tangent, and pivot. A line in any other shape is not a proposal.
505
+ * blocker, tangent, and pivot. A line in any other shape is not a proposal,
506
+ * and a field line is read only indented beneath one.
531
507
  */
532
508
  export function harvestProposals(detail) {
533
509
  const lines = detail.split('\n');
@@ -536,10 +512,20 @@ export function harvestProposals(detail) {
536
512
  return [];
537
513
  }
538
514
  const end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
539
- return lines.slice(start + 1, end === -1 ? lines.length : end).flatMap((line) => {
540
- const m = /^-\s+(?:P|park\s+)(\d+)\s*(?:→|->)\s*([\w-]+)\s*(?::\s*(.*))?$/i.exec(line.trim());
541
- return m === null ? [] : [{ handle: Number(m[1]), kind: (m[2] ?? '').toLowerCase(), reason: (m[3] ?? '').trim() }];
542
- });
515
+ const proposals = [];
516
+ for (const line of lines.slice(start + 1, end === -1 ? lines.length : end)) {
517
+ const m = PROPOSAL.exec(line.trim());
518
+ if (m !== null) {
519
+ proposals.push({ handle: Number(m[1]), kind: (m[2] ?? '').toLowerCase(), reason: (m[3] ?? '').trim(), title: '', why: '' });
520
+ continue;
521
+ }
522
+ const field = /^\s+/.test(line) ? PROPOSAL_FIELD.exec(line.trim()) : null;
523
+ const last = proposals.at(-1);
524
+ if (field !== null && last !== undefined) {
525
+ last[(field[1] ?? '').toLowerCase() === 'title' ? 'title' : 'why'] = (field[2] ?? '').trim();
526
+ }
527
+ }
528
+ return proposals;
543
529
  }
544
530
  /**
545
531
  * The range a detail file reviews, read off its `# Detail · Range <n> · <stop>`
@@ -557,10 +543,10 @@ export function parseDetailRange(detail) {
557
543
  * still parsing and reading as landed. The record clears the stamp and the
558
544
  * detail file, so the wait ends with it.
559
545
  *
560
- * The digest is left to the record's own latch: a detail file rewritten for
561
- * a re-render that has not run yet still waits, and a stamp from before the
562
- * digest carries none. The landed condition is what keeps a re-cut from
563
- * wedging the build, since a range re-cut to take a new step stops waiting.
546
+ * A detail file rewritten since the card showed (a lean the human changed)
547
+ * still waits, since the stamp names the range and nothing it said. The
548
+ * landed condition is what keeps a re-cut from wedging the build, since a
549
+ * range re-cut to take a new step stops waiting.
564
550
  * The stamp is the build's own but the detail file is the session's, so in a
565
551
  * session of several builds, a stamp left in this build and another build's
566
552
  * card for the same range number read as this build's card waiting.
@@ -578,29 +564,86 @@ export function waitingCard(root, slug, intent) {
578
564
  return { range: range.item, count: plan.ranges.length + plan.malformed.length };
579
565
  }
580
566
  /**
581
- * The Harvested part: a lead that sizes the list, then one bullet per open
582
- * park in list order, its `P6` handle and text from the list and, behind the
583
- * arrow, the lean the detail file proposes: `lean: tangent, <the reason>`. A
584
- * park the detail left out reads `no lean proposed`, so a silent omission is
585
- * one on the card. Null with no park open, which renders the card without the
586
- * part.
567
+ * The Harvested part: a lead that sizes the list, then the lean table, one row
568
+ * per open park in list order (D112 (lean-table)). The first column is the
569
+ * park's `P6` handle with the short title the detail file gives it, the second
570
+ * the park's text exactly as the list holds it, so the card can never
571
+ * misquote a park, the third why it matters, and the fourth the lean:
572
+ * `tangent: <the reason>`. A park the detail left out reads
573
+ * `no lean proposed`, and a missing why reads `not given`, so a silent
574
+ * omission is one on the card. Null with no park open, which renders the card
575
+ * without the part.
587
576
  *
588
- * A bullet, never a numbered item, because a markdown list renumbers: open
589
- * parks 6 and 8 would render as 6 and 7, and the number read would not be the
590
- * one the record matches (D105 (park-handles)).
577
+ * The handle rides its own cell, never a list number, because the number the
578
+ * human reads has to be the one the record matches (D105 (park-handles)).
591
579
  */
592
580
  function harvestPart(parks, proposals) {
593
581
  const open = parks.filter((p) => p.open);
594
582
  if (open.length === 0) {
595
583
  return null;
596
584
  }
597
- const items = open.map((park) => {
585
+ const rows = open.map((park) => {
598
586
  const proposal = proposals.find((p) => p.handle === park.handle);
599
- const lean = proposal === undefined ? 'no lean proposed' : proposal.reason.length === 0 ? `lean: ${proposal.kind}` : `lean: ${proposal.kind}, ${proposal.reason}`;
600
- return `- P${park.handle} · ${park.text} → ${lean}`;
587
+ const name = proposal === undefined || proposal.title.length === 0 ? `P${park.handle}` : `P${park.handle} · ${proposal.title}`;
588
+ const why = proposal === undefined || proposal.why.length === 0 ? 'not given' : proposal.why;
589
+ const lean = proposal === undefined ? 'no lean proposed' : proposal.reason.length === 0 ? proposal.kind : `${proposal.kind}: ${proposal.reason}`;
590
+ return `| ${cell(name)} | ${cell(park.text)} | ${cell(why)} | ${cell(lean)} |`;
601
591
  });
602
592
  const lead = `**Harvested**: ${open.length} of ${parks.length} parked idea${parks.length === 1 ? '' : 's'} open`;
603
- return [lead, '', ...items].join('\n');
593
+ return [lead, '', LEAN_TABLE_HEAD, '| --- | --- | --- | --- |', ...rows].join('\n');
594
+ }
595
+ // The lean table's header row, the same on the stop card, a boundary
596
+ // harvest's render, and the stop's record.
597
+ const LEAN_TABLE_HEAD = '| Park | What it is | Why it matters | Lean |';
598
+ /**
599
+ * A boundary harvest's leans (`plumbbob handoff --harvest`): the Harvested
600
+ * part, the lean table the stop card carries, read from a `# Detail · Harvest`
601
+ * file, then the moves that settle it in one reply. A driver-tier render with
602
+ * no stamp: nothing is pending on the session, and the record waits for no
603
+ * card. It refuses a detail file that is no harvest's, and a list with no park
604
+ * open, since there is then nothing to propose.
605
+ */
606
+ function harvestCard(root, slug) {
607
+ const detail = settleDetail(root, readOr(detailPath(root)));
608
+ if (!isHarvestDetail(detail)) {
609
+ process.stderr.write(notice({
610
+ fact: 'no harvest to render',
611
+ detail: ["the detail file is not a harvest's"],
612
+ remedy: 'write the proposals in .plumbbob/detail.md under `# Detail · Harvest`, then plumbbob handoff --harvest',
613
+ }));
614
+ return 1;
615
+ }
616
+ const parks = parkList(readOr(buildLogPath(root, slug)));
617
+ const proposals = harvestProposals(detail);
618
+ const part = harvestPart(parks, proposals);
619
+ if (part === null) {
620
+ process.stderr.write(notice({ fact: 'no harvest to render', detail: ['no park is open'], remedy: 'plumbbob park "<one line>" captures one' }));
621
+ return 1;
622
+ }
623
+ return emit(root, { tier: 'driver', render: 'harvest-card' }, [part, harvestCallBlock(leanExample(parks, proposals))]);
624
+ }
625
+ /**
626
+ * A boundary harvest's Your Call block: the lock-in line, then the moves that
627
+ * settle every lean in one reply, as at a stop with no run to name.
628
+ */
629
+ function harvestCallBlock(example) {
630
+ return [
631
+ '**Your Call**:',
632
+ '',
633
+ `Approving locks in each lean above; to change one, say so first (\`${example ?? 'make P1 a blocker'}\`).`,
634
+ '',
635
+ callLine('`looks good`', 'I record every lean as proposed, folding a blocker into the plan'),
636
+ callLine('anything that reads as direction', 'I change the lean and say what moved; nothing is recorded until you approve'),
637
+ callLine('`expand`, or any question', 'I show more of what is there; nothing changes'),
638
+ ].join('\n');
639
+ }
640
+ /**
641
+ * Whether a detail file is a boundary harvest's wire, read off its
642
+ * `# Detail · Harvest` header.
643
+ */
644
+ export function isHarvestDetail(detail) {
645
+ const first = detail.trimStart().split('\n', 1)[0] ?? '';
646
+ return /^#\s+Detail\s*·\s*Harvest\s*$/.test(first);
604
647
  }
605
648
  /**
606
649
  * The stop card's Your Call block: the replies that settle the card in one
@@ -631,7 +674,7 @@ function stopCallBlock(example, intent) {
631
674
  else if (follow !== null) {
632
675
  moves.push(callLine('`/plumbbob:run`', `I record ${what} the same way, then run range ${follow.range.item} of ${follow.count} to its stop`));
633
676
  }
634
- moves.push(callLine('anything that reads as direction', `I re-render the card with the ${parked ? 'lean' : 'review'} changed; nothing is recorded until you approve`), callLine('`expand`, or any question', 'I show more of what is there; nothing changes, and `show the stop` brings all of this back'));
677
+ moves.push(callLine('anything that reads as direction', `I change the ${parked ? 'lean' : 'review'} and say what moved; nothing is recorded until you approve`), callLine('`expand`, or any question', 'I show more of what is there; nothing changes, and `show the stop` brings all of this back'));
635
678
  const lockIn = parked ? [`Approving locks in each lean above; to change one, say so first (\`${example}\`).`, ''] : [];
636
679
  return ['**Your Call**:', '', ...lockIn, ...moves].join('\n');
637
680
  }
@@ -679,7 +722,7 @@ export function stopRecord(root, slug, detail, buildLog) {
679
722
  if (review.readout.length > 0) {
680
723
  parts.push([READOUT_LABEL, '', ...fence('text', review.readout)].join('\n'));
681
724
  }
682
- parts.push(review.verdict);
725
+ parts.push(verdictLine(review.verdict));
683
726
  const harvest = harvestPart(parkList(buildLog), harvestProposals(detail));
684
727
  if (harvest !== null) {
685
728
  parts.push(harvest);
@@ -717,6 +760,7 @@ export function stepRecord(root, slug, step, detail) {
717
760
  const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, step));
718
761
  const readout = recapLines(rows, {
719
762
  diff: diffRowValue(countDiff(work), false),
763
+ files: diffFileItems(work),
720
764
  spent: spentRowValue(spentInputs(root, slug, step, check)),
721
765
  constraints: parseConstraintCount(intent),
722
766
  });
@@ -727,7 +771,7 @@ export function stepRecord(root, slug, step, detail) {
727
771
  if (readout.length > 0) {
728
772
  parts.push([READOUT_LABEL, '', ...fence('text', readout)].join('\n'));
729
773
  }
730
- parts.push(verdictLine(ladder, worst));
774
+ parts.push(verdictLine(verdictValue(ladder, worst)));
731
775
  return [...withRecommendation(parts, recommendation), ...sectionBlocks(sections)].join('\n\n');
732
776
  }
733
777
  /**
@@ -786,12 +830,12 @@ export function driverPointer(root, slug) {
786
830
  }
787
831
  /**
788
832
  * The Verdict for a step that just landed: the assembled rows folded worst-of
789
- * with the step's accrued stats, rendered as the line.
833
+ * with the step's accrued stats, as the state table's header states it.
790
834
  */
791
835
  function landedVerdict(root, slug, intent, step) {
792
836
  const { rows } = measured(root, slug, intent, readOr(detailPath(root)), step);
793
837
  const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, step));
794
- return verdictLine(ladder, worst);
838
+ return verdictValue(ladder, worst);
795
839
  }
796
840
  /**
797
841
  * Assemble the rows once, so the fence and the fold read the same set: the
@@ -837,36 +881,38 @@ function reprintCommand(form, step, build) {
837
881
  return parts.filter((p) => p !== null && p !== undefined).join(' ');
838
882
  }
839
883
  /**
840
- * The Verdict line from an already-folded result: the ladder rung, and the
841
- * worst component in a trailing parenthetical when one exists.
884
+ * The Verdict's fact from an already-folded result: the ladder rung, and the
885
+ * worst component in a trailing parenthetical when one exists. The state
886
+ * table's header row carries it on a card; the record keeps it as a line.
842
887
  *
843
- * No step segment rides here. The identity renders once per turn: the Now line
888
+ * No step segment rides here. The identity renders once per turn: the Now row
844
889
  * names the step in flight with its count, Next Up names the step to come with
845
890
  * its own, and the Verdict is left saying the one thing only it says.
846
891
  */
847
- function verdictLine(ladder, worst) {
848
- const state = `**Verdict**: ${ladder.glyph} ${ladder.state}`;
892
+ function verdictValue(ladder, worst) {
893
+ const state = `${ladder.glyph} ${ladder.state}`;
849
894
  return worst === null ? state : `${state} (${worst})`;
850
895
  }
851
896
  /**
852
- * The turn's opening block: the model's lead behind the Summary label, with the
853
- * `(details: …)` bracket appended, then the numbered highlights beneath it.
897
+ * The Verdict as the build-log record keeps it, a labeled line, since the
898
+ * record is the archive and carries no state table.
899
+ */
900
+ function verdictLine(value) {
901
+ return `**Verdict**: ${value}`;
902
+ }
903
+ /**
904
+ * The card's opening block: the model's lead behind the Summary label, and
905
+ * nothing beneath it.
854
906
  *
855
- * The lead and the titles are the markdown the model wrote, passed through; the
856
- * label, the bracket, and the numbering are the CLI's, so the model never types
857
- * a path and the block cannot drift from where the detail actually is. Empty
858
- * when the detail file carries no lead: a Summary that says nothing vanishes
859
- * rather than labeling nothing.
907
+ * The numbered highlights stay in the detail file, behind the `more details`
908
+ * link the card closes on and the `expand` move, and the build-log record
909
+ * still lists them: the card is read for the Verdict, the lead, and the call,
910
+ * and a list read past is a list in the way (D113 (short-card)). Empty when
911
+ * the detail file carries no lead: a Summary that says nothing vanishes rather
912
+ * than labeling nothing.
860
913
  */
861
- function summaryBlock(summary, where) {
862
- if (summary === null) {
863
- return '';
864
- }
865
- const lead = `**Summary**: ${summary.lead} (${detailsLink(where, null)})`;
866
- if (summary.highlights.length === 0) {
867
- return lead;
868
- }
869
- return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
914
+ function summaryBlock(summary) {
915
+ return summary === null ? '' : `**Summary**: ${summary.lead}`;
870
916
  }
871
917
  // The Readout's label, bare on the card and in the record alike: the Now line
872
918
  // at the top of the card names the step the fence measures, and a label that
@@ -948,13 +994,25 @@ function fence(lang, lines) {
948
994
  const RECOMMENDATION_LABEL = '**Recommendation**: ';
949
995
  /**
950
996
  * Append the recommendation beneath a block, blank-line separated, when the
951
- * model wrote one; the ending's last text is then the model's own call. The
952
- * label is the CLI's: it announces what the last text is before the eye
953
- * reads it, and a label the model typed would be one more line to drift.
997
+ * model wrote one. The label is the CLI's: it announces what the text is
998
+ * before the eye reads it, and a label the model typed would be one more line
999
+ * to drift.
954
1000
  */
955
1001
  function withRecommendation(parts, recommendation) {
956
1002
  return recommendation === null ? [...parts] : [...parts, `${RECOMMENDATION_LABEL}${recommendation}`];
957
1003
  }
1004
+ /**
1005
+ * A card's foot: the recommendation the detail file carries, then one
1006
+ * `more details` link to the file, the card's last line.
1007
+ *
1008
+ * The link is the one place the card points at the detail, out of the way of
1009
+ * the parts the human reads (D113 (short-card)). It renders only when the file
1010
+ * holds something, since a link to an empty file points at nothing.
1011
+ */
1012
+ function cardFoot(parts, detail, where) {
1013
+ const foot = withRecommendation(parts, parseRecommendation(detail));
1014
+ return detail.trim().length === 0 ? foot : [...foot, `[more details](${where})`];
1015
+ }
958
1016
  /**
959
1017
  * The step's accrued stats (seam strays, red checks, reverts) plus commits that
960
1018
  * landed since the last ledger entry outside plumbbob's checkpoints: the
@@ -1013,7 +1071,7 @@ function yourCallBlock(step, checkGreen, run) {
1013
1071
  }
1014
1072
  }
1015
1073
  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'));
1016
- moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
1074
+ moves.push(callLine('anything that reads as direction', 'I make the change and say what moved; nothing lands until you approve'));
1017
1075
  moves.push(callLine('`revert`', 'I wind the work back to the last checkpoint'));
1018
1076
  return ['**Your Call**:', '', ...moves].join('\n');
1019
1077
  }
@@ -1068,55 +1126,56 @@ function spikeNextUpLine(inFlight) {
1068
1126
  const back = inFlight === null ? '' : `, then back to Step ${inFlight}`;
1069
1127
  return `**Next Up**: Close the spike - /plumbbob:spike done${back}`;
1070
1128
  }
1071
- // The card's cap on the build-order reminder: the next five steps, the rest a
1072
- // count. The line is a reminder; the dashboard holds the whole sequence.
1129
+ // The Order row's cap: the next five steps, the rest a count. The row is a
1130
+ // reminder; the dashboard holds the whole sequence.
1073
1131
  const ORDER_CAP = 5;
1074
1132
  /**
1075
- * The line beneath the forward pointer: the range segment, when the plan
1076
- * declares build ranges; the build order from the step the pointer names, when
1077
- * `## Build order` re-sequences the plan away from its numbering; and the park
1078
- * count, when anything has been parked. '' when none says anything, so a plan
1079
- * with no ranges, nobody re-sequenced, and an empty park list renders the
1080
- * pointer alone, as before.
1133
+ * The state table's rows, in their fixed order, each only when it says
1134
+ * something: the Verdict, the step in flight, the Stop, Next Up, the range the
1135
+ * next run walks, the build order where `## Build order` re-sequences the
1136
+ * plan, the parks once anything has been parked, and the open questions the
1137
+ * plan still carries. The first row present becomes the header, which is the
1138
+ * Verdict wherever one was measured (D111 (state-table)).
1081
1139
  *
1082
- * Indented two spaces, because an indented line continues the part above it
1083
- * to every reader of the anatomy (the advisory's `→` remedy line is the
1084
- * precedent), where a flush one would open a stray. The parks ride even under
1085
- * `Nothing planned`: an unharvested list at the end of the plan is the one
1086
- * reminder that matters there.
1140
+ * The Range, Order, and Parked rows are the segments the note beneath Next Up
1141
+ * used to carry on one indented line; as rows each one has a label of its own,
1142
+ * the way the human scans for it.
1087
1143
  */
1088
- function nextUpNote(intent, buildLog, exclude, range) {
1089
- const parks = parseParkTotals(buildLog);
1090
- const segments = [
1091
- range,
1092
- buildOrderPhrase(remainingOrder(parseSteps(intent), parseBuildOrder(intent), exclude), ORDER_CAP),
1093
- parks.total > 0 ? parkedPhrase(parks) : '',
1094
- ].filter((s) => s.length > 0);
1095
- return segments.length === 0 ? '' : ` · ${segments.join(' · ')}`;
1144
+ function stateRows(input) {
1145
+ const steps = parseOrderedSteps(input.intent);
1146
+ const range = rangeSegment(input.intent, steps, input.nextUp, input.exclude);
1147
+ const order = buildOrderPhrase(remainingOrder(parseSteps(input.intent), parseBuildOrder(input.intent), input.exclude), ORDER_CAP);
1148
+ const parks = parseParkTotals(input.buildLog);
1149
+ const questions = parseOpenQuestions(input.intent);
1150
+ const rows = [
1151
+ input.verdict === null ? null : { label: 'Verdict', value: input.verdict },
1152
+ input.now === null ? null : { label: 'Now', value: input.now },
1153
+ input.stop === null ? null : { label: 'Stop', value: input.stop },
1154
+ { label: 'Next Up', value: pointerText(input.nextUp, steps.length, input.where, range.model ?? input.nextUp?.model ?? null) },
1155
+ range.segment.length === 0 ? null : { label: 'Range', value: range.segment },
1156
+ order.length === 0 ? null : { label: 'Order', value: order },
1157
+ parks.total === 0 ? null : { label: 'Parked', value: `${parks.open} of ${parks.total} open` },
1158
+ questions === 0 ? null : { label: 'Questions', value: `${questions} open` },
1159
+ ];
1160
+ return rows.filter((r) => r !== null);
1096
1161
  }
1097
1162
  /**
1098
- * The forward pointer with its note beneath, as one part: `blocks` keeps a
1099
- * part's own newlines, so the note rides flush under the line it qualifies.
1100
- *
1101
- * `exclude` is the step the turn is about: the step in flight at a pause, the
1102
- * step just landed at a boundary, null at the plan pause and the plan commit.
1103
- * While the note suggests a range, the bracket bolds the range's model, since
1104
- * the run is the range and that is the `/model` the human picks before typing
1105
- * it.
1163
+ * The bare forward pointer, the one line a boundary already shown ends on:
1164
+ * the Next Up line the driver tier keeps, with the range's model in its
1165
+ * bracket while the next run is a range, as the table's Next Up row has it.
1106
1166
  */
1107
- function nextUpBlock(intent, buildLog, nextUp, exclude, where) {
1167
+ function pointerLine(intent, nextUp, exclude, where) {
1108
1168
  const steps = parseOrderedSteps(intent);
1109
1169
  const range = rangeSegment(intent, steps, nextUp, exclude);
1110
- const line = nextUpLine(nextUp, steps.length, where, range.model ?? nextUp?.model ?? null);
1111
- const note = nextUpNote(intent, buildLog, exclude, range.segment);
1112
- return note.length === 0 ? line : `${line}\n${note}`;
1170
+ return nextUpLine(nextUp, steps.length, where, range.model ?? nextUp?.model ?? null);
1113
1171
  }
1114
1172
  /**
1115
- * The range segment that leads the note, and the model the pointer's bracket
1116
- * bolds while it suggests a range: the run `/plumbbob:run` typed now would
1117
- * walk, `range 3 of 5: /plumbbob:run (steps 6-9)`, with the model of the range
1118
- * holding the step Next Up names; the re-cut words for a flagged range; the
1119
- * words for a step no range names; or '' for a plan without ranges.
1173
+ * The Range row's fact, and the model the pointer's bracket bolds while it
1174
+ * suggests a range: the run `/plumbbob:run` typed now would walk,
1175
+ * `3 of 5: /plumbbob:run (steps 6-9)`, with the model of the range holding
1176
+ * the step Next Up names; the re-cut words for a flagged range; the words for
1177
+ * a step no range names; or '' for a plan without ranges. The row's label
1178
+ * says `Range`, so the fact opens on the range's count.
1120
1179
  *
1121
1180
  * The bracket is the grant the hook would mint. At a step pause that is the
1122
1181
  * range holding the step in flight, its bracket opening on that step, since a
@@ -1134,14 +1193,14 @@ function rangeSegment(intent, steps, nextUp, exclude) {
1134
1193
  const paused = steps.some((s) => s.n === exclude && !s.done) ? exclude : null;
1135
1194
  const run = cardRun(intent, paused);
1136
1195
  if (run.ok) {
1137
- const name = `range ${run.range.item} of ${run.count}`;
1196
+ const name = `${run.range.item} of ${run.count}`;
1138
1197
  const lands = paused !== null && run.left.length === 1;
1139
1198
  return { segment: lands ? `${name} lands with step ${paused}` : `${name}: /plumbbob:run (${runBracket(run)})`, model };
1140
1199
  }
1141
1200
  if (run.kind === 'uncovered') {
1142
1201
  return { segment: run.words, model };
1143
1202
  }
1144
- return { segment: run.kind === 'flagged' ? `range ${run.item} of ${run.count} needs a re-cut (${run.words})` : '', model };
1203
+ return { segment: run.kind === 'flagged' ? `${run.item} of ${run.count} needs a re-cut (${run.words})` : '', model };
1145
1204
  }
1146
1205
  /**
1147
1206
  * The run `/plumbbob:run` typed now would walk, as the card names it: the
@@ -1153,26 +1212,33 @@ function cardRun(intent, paused) {
1153
1212
  return resolveRun({ intent, inFlight: paused, spiking: false, item: null });
1154
1213
  }
1155
1214
  /**
1156
- * The forward pointer: the next undone step with the progress count, its
1157
- * title, and a closing bracket carrying its advisory `- model:` recommendation
1158
- * and where to read the step in full. No next step ⇒ the finish/step nudge
1159
- * instead.
1215
+ * The forward pointer's fact: the next undone step with the progress count,
1216
+ * its title, and a closing bracket carrying its advisory `- model:`
1217
+ * recommendation and where to read the step in full. No next step ⇒ the
1218
+ * finish/step nudge instead.
1160
1219
  *
1161
1220
  * The model is the second bold token the line spends, because it is the one
1162
1221
  * the human acts on (a `/model` call) before the next run; the path is the
1163
- * short link every `details:` bracket carries (`detailsLink`). The line alone
1164
- * is the driver tier's pointer; every other tier rides `nextUpNote` beneath
1165
- * it, through `nextUpBlock`.
1222
+ * short link every `details:` bracket carries (`detailsLink`). The state
1223
+ * table's Next Up row carries it; `nextUpLine` labels it for the one line a
1224
+ * driver turn ends on.
1166
1225
  */
1167
- function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
1226
+ function pointerText(nextUp, total, where, model = nextUp?.model ?? null) {
1168
1227
  if (nextUp === undefined) {
1169
- return '**Next Up**: Nothing planned - /plumbbob:step or /plumbbob:finish';
1228
+ return 'Nothing planned - /plumbbob:step or /plumbbob:finish';
1170
1229
  }
1171
1230
  const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
1172
1231
  const token = modelToken(model);
1173
1232
  const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
1174
1233
  bracket.push(detailsLink(where, nextUp.line));
1175
- return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
1234
+ return `Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
1235
+ }
1236
+ /**
1237
+ * The forward pointer as one labeled line: the driver tier's pointer, and the
1238
+ * line a boundary already shown ends on.
1239
+ */
1240
+ function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
1241
+ return `**Next Up**: ${pointerText(nextUp, total, where, model)}`;
1176
1242
  }
1177
1243
  /**
1178
1244
  * The model token from a `- model:` recommendation: the first word, so a