plumbbob 0.13.5 → 0.13.7

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.
@@ -49,8 +49,9 @@ import { PLAN_COMMIT_MARKER, commitNumstat, commitsBetween, commitsSince, diffNu
49
49
  import { blocks, cell, notice, printEnding, stateTable } from "../lib/notice.js";
50
50
  import { hasStep } from "../lib/steplist.js";
51
51
  import { isArtifactPath, parseStepSeam } from "../lib/intent.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
+ import { rerootLinks } from "../lib/links.js";
53
+ import { buildFolder, buildFolderIgnored, buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, planCommitted, readPause, readStats, readStop, readTurn, resolveBuild, seamPath, sidecarDir, stepPath, tickPath, writePause, writeStop, } from "../lib/sidecar.js";
54
+ import { buildOrderPhrase, countDiff, diffFileItems, diffRowValue, foldVerdict, lastLedgerSha, measureRange, normalizeDetailHeadings, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOpenQuestionRows, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, questionTotals, rangeLanded, rangeSpan, rangeStanding, recapLines, remainingOrder, resolveRun, runBracket, seamRowFromDiff, spentRowValue, stopFollowUp, summaryCheckRow, } from "../lib/orient.js";
54
55
  // The inline-diff threshold (docs/presentation.md): at 20 changed lines or
55
56
  // fewer the patch rides the turn; at 21 it stays in the working tree behind
56
57
  // the counted diff row.
@@ -123,10 +124,17 @@ export function handoff(cwd, args = []) {
123
124
  // decision turn still ends on the model's recommendation when it wrote one.
124
125
  const first = steps.find((s) => !s.done);
125
126
  const detail = settleDetail(root, rawDetail);
127
+ // The open questions ride above the moves as their own table, which says
128
+ // what the Questions row would count, so the row stays off this card. The
129
+ // moves that settle them ride only while one waits on the human: a
130
+ // question deferred to the work is no call to make now.
131
+ const openRows = parseOpenQuestionRows(intent);
132
+ const questions = questionsPart(openRows, where);
126
133
  const plan = [
127
134
  SEAM_RULE,
128
- planCallBlock(first, cardRun(intent, null)),
129
- stateTable(stateRows({ intent, buildLog, verdict: null, now: null, stop: null, nextUp: first, exclude: null, where })),
135
+ questions ?? '',
136
+ planCallBlock(first, cardRun(intent, null), planPublish(root, slug), questionTotals(openRows).toAnswer > 0),
137
+ stateTable(stateRows({ intent, buildLog, verdict: null, now: null, stop: null, nextUp: first, exclude: null, where, questions: false })),
130
138
  ];
131
139
  const command = reprintCommand('--plan', undefined, named);
132
140
  return emit(root, { tier: 'decision', render: 'plan', command }, cardFoot(plan, detail, detailWhere));
@@ -169,7 +177,7 @@ export function handoff(cwd, args = []) {
169
177
  // The boundary the human already read: the reply above this line is what
170
178
  // they asked for, and the block would push it off the screen. Driver
171
179
  // tier, since nothing is pending to hold the turn for.
172
- return emit(root, { tier: 'driver', render: 'pointer' }, [pointerLine(intent, nextUp, current, where)]);
180
+ return emit(root, { tier: 'driver', render: 'pointer' }, [pointerLine(nextUp, where)]);
173
181
  }
174
182
  // The boundary: the orientation-tier ending, the state table headed by the
175
183
  // Verdict, with the Stop row where a range just landed, and no decision
@@ -301,10 +309,10 @@ function landedRange(intent, steps, step) {
301
309
  * The Stop row's fact: why the build stops at this boundary. The range over
302
310
  * the count, a plain hyphen, the stop as the plan wrote it, and a bracket
303
311
  * pointing at the item, where its note can be read. The stop card's state
304
- * table carries the same row.
312
+ * table carries the same row, the range bold (D115 (card-weights)).
305
313
  */
306
314
  function stopValue(landed, where) {
307
- return `Range ${landed.range.item} of ${landed.count} landed - ${landed.range.stop} (${detailsLink(where, landed.range.line)})`;
315
+ return `**Range ${landed.range.item}** landed - ${landed.range.stop} (${detailsLink(where, landed.range.line)})`;
308
316
  }
309
317
  /**
310
318
  * The `details:` clause every bracket on a card carries, in its short linked
@@ -332,10 +340,19 @@ export function detailsLink(path, line) {
332
340
  * step in flight, with no range just landed, and with a detail file written
333
341
  * for anything but that range, since a card with none of the model's review
334
342
  * 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.
343
+ * the STOP stamp names the range, a render in a later turn is the one-line
344
+ * reminder, whatever changed since (a lean the human changed, a park, a gate
345
+ * run), and `--full` prints the card again. A render in the turn the card
346
+ * printed in prints it again in the same form, since the human has not read
347
+ * it yet: that is the retry the relay check asks for when a turn dropped it.
348
+ * Every card it prints rewrites the stamp with the turn it printed in, which
349
+ * is the turn `plumbbob harvest` waits past.
350
+ *
351
+ * When the landing that completed the range answered that step's own pause
352
+ * card, already shown, the human read that step's Summary, Readout, and
353
+ * recommendation a moment ago, so the card prints short: the Harvested part,
354
+ * Your Call, the state table with its Stop row, and the link, and `--full`
355
+ * prints it whole (D119 (short-stop-after-a-halt)).
339
356
  */
340
357
  function stopCard(root, slug, named, full) {
341
358
  const intent = readOr(intentPath(root, slug));
@@ -345,7 +362,7 @@ function stopCard(root, slug, named, full) {
345
362
  process.stderr.write(landed);
346
363
  return 1;
347
364
  }
348
- const { range, count, step } = landed;
365
+ const { range, step } = landed;
349
366
  const detail = settleDetail(root, readOr(detailPath(root)));
350
367
  if (parseDetailRange(detail) !== range.item) {
351
368
  process.stderr.write(notice({
@@ -356,22 +373,26 @@ function stopCard(root, slug, named, full) {
356
373
  return 1;
357
374
  }
358
375
  const stamp = readStop(root, slug);
359
- if (!full && stamp !== null && stamp.range === range.item) {
376
+ const turn = readTurn(root);
377
+ const printedThisTurn = turn !== null && stamp?.turn === turn;
378
+ if (!full && stamp !== null && stamp.range === range.item && !printedThisTurn) {
360
379
  // The stop the human already read: the reply above this line is what they
361
380
  // asked for, and the card would push it off the screen.
362
381
  const command = reprintCommand('--stop', undefined, named);
363
382
  return emit(root, { tier: 'decision', render: 'stop-reminder', command }, [
364
- `**Next Up**: Back to Range ${range.item} of ${count}'s stop - ${range.stop}`,
383
+ `**Next Up**: Back to **Range ${range.item}**'s stop - ${range.stop}`,
365
384
  ]);
366
385
  }
367
- writeStop(root, slug, { range: range.item, turn: readTurn(root) });
386
+ writeStop(root, slug, { range: range.item, turn });
368
387
  const where = relative(root, intentPath(root, slug));
369
388
  const review = stopReview(root, slug, intent, range, detail);
370
389
  const parks = parkList(readOr(buildLogPath(root, slug)));
371
390
  const proposals = harvestProposals(detail);
372
391
  const nextUp = steps.find((s) => !s.done && s.n !== step);
373
- const parts = [summaryBlock(parseSummary(detail))];
374
- if (review.readout.length > 0) {
392
+ const shown = readPause(root, slug);
393
+ const short = !full && shown?.kind === 'landed' && shown.step === step && shown.answered === true;
394
+ const parts = short ? [] : [summaryBlock(parseSummary(detail))];
395
+ if (!short && review.readout.length > 0) {
375
396
  parts.push([READOUT_LABEL, '', ...fence('text', review.readout)].join('\n'));
376
397
  }
377
398
  parts.push(harvestPart(parks, proposals) ?? '', stopCallBlock(leanExample(parks, proposals), intent), stateTable(stateRows({
@@ -385,8 +406,15 @@ function stopCard(root, slug, named, full) {
385
406
  exclude: step,
386
407
  where,
387
408
  })));
388
- const command = reprintCommand('--stop --full', undefined, named);
389
- return emit(root, { tier: 'decision', render: 'stop', command }, cardFoot(parts, detail, relative(root, detailPath(root))));
409
+ // A retry in this turn reprints the card as it printed: `--stop` rebuilds a
410
+ // short card from the PAUSE stamp the landing left, and a card shown whole
411
+ // on request comes back whole (D119 (short-stop-after-a-halt)).
412
+ const command = reprintCommand(full ? '--stop --full' : '--stop', undefined, named);
413
+ const detailWhere = relative(root, detailPath(root));
414
+ // The short card keeps the link, one line that repeats nothing, and leaves
415
+ // the recommendation the pause card just gave.
416
+ const foot = short ? [...parts, `[more details](${detailWhere})`] : cardFoot(parts, detail, detailWhere);
417
+ return emit(root, { tier: 'decision', render: 'stop', command }, foot);
390
418
  }
391
419
  /**
392
420
  * The range the stop card reviews: the one the last landing completed, or the
@@ -589,7 +617,7 @@ function harvestPart(parks, proposals) {
589
617
  const lean = proposal === undefined ? 'no lean proposed' : proposal.reason.length === 0 ? proposal.kind : `${proposal.kind}: ${proposal.reason}`;
590
618
  return `| ${cell(name)} | ${cell(park.text)} | ${cell(why)} | ${cell(lean)} |`;
591
619
  });
592
- const lead = `**Harvested**: ${open.length} of ${parks.length} parked idea${parks.length === 1 ? '' : 's'} open`;
620
+ const lead = `**Harvested**: **${open.length}** of ${parks.length} parked idea${parks.length === 1 ? '' : 's'} open`;
593
621
  return [lead, '', LEAN_TABLE_HEAD, '| --- | --- | --- | --- |', ...rows].join('\n');
594
622
  }
595
623
  // The lean table's header row, the same on the stop card, a boundary
@@ -672,7 +700,7 @@ function stopCallBlock(example, intent) {
672
700
  moves.push(callLine('`/plumbbob:finish`', `I record ${what} the same way, then write the report and close the build`));
673
701
  }
674
702
  else if (follow !== null) {
675
- moves.push(callLine('`/plumbbob:run`', `I record ${what} the same way, then run range ${follow.range.item} of ${follow.count} to its stop`));
703
+ moves.push(callLine('`/plumbbob:run`', `I record ${what} the same way, then run range ${follow.range.item}${onModel(follow.range.model)} to its stop`));
676
704
  }
677
705
  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'));
678
706
  const lockIn = parked ? [`Approving locks in each lean above; to change one, say so first (\`${example}\`).`, ''] : [];
@@ -706,7 +734,8 @@ function leanExample(parks, proposals) {
706
734
  * part shows each class the human confirmed. Null when the detail file
707
735
  * reviews no range the plan declares.
708
736
  */
709
- export function stopRecord(root, slug, detail, buildLog) {
737
+ export function stopRecord(root, slug, written, buildLog) {
738
+ const detail = recordText(root, slug, written);
710
739
  const intent = readOr(intentPath(root, slug));
711
740
  const item = parseDetailRange(detail);
712
741
  const range = parseBuildRanges(intent).ranges.find((r) => r.item === item);
@@ -745,7 +774,8 @@ export function stopRecord(root, slug, detail, buildLog) {
745
774
  * than a requirement. Null when the detail file carries nothing to record, so a
746
775
  * hand-built step with no detail gets the bare dated line it always did.
747
776
  */
748
- export function stepRecord(root, slug, step, detail) {
777
+ export function stepRecord(root, slug, step, written) {
778
+ const detail = recordText(root, slug, written);
749
779
  const summary = parseSummary(detail);
750
780
  const recommendation = parseRecommendation(detail);
751
781
  const sections = parseSections(detail);
@@ -779,7 +809,8 @@ export function stepRecord(root, slug, step, detail) {
779
809
  * findings behind it. Nothing is measured at the plan pause, so no Readout and
780
810
  * no Verdict ride here. Null when the detail file carries neither.
781
811
  */
782
- export function planRecord(detail) {
812
+ export function planRecord(root, slug, written) {
813
+ const detail = recordText(root, slug, written);
783
814
  const recommendation = parseRecommendation(detail);
784
815
  const sections = parseSections(detail);
785
816
  if (recommendation === null && sections.length === 0) {
@@ -787,6 +818,16 @@ export function planRecord(detail) {
787
818
  }
788
819
  return [...withRecommendation([], recommendation), ...sectionBlocks(sections)].join('\n\n');
789
820
  }
821
+ /**
822
+ * The detail file's text as the build log reads it: every relative link
823
+ * re-aimed from `.plumbbob/`, where `detail.md` lives, to the build folder the
824
+ * log lives in, so a link that worked while the pause was open still works in
825
+ * the record (D116 (links-re-rooted)). Under `--local` the two are one folder
826
+ * and the text comes back as written.
827
+ */
828
+ function recordText(root, slug, written) {
829
+ return rerootLinks(written, sidecarDir(root), buildFolder(root, slug));
830
+ }
790
831
  /**
791
832
  * The record's opening block: the Summary lead without the `(details: …)`
792
833
  * bracket, since the record is where the detail now lives, then the
@@ -826,7 +867,7 @@ export function driverPointer(root, slug) {
826
867
  // work already recorded.
827
868
  const landed = parseLastCheckpoint(readOr(checkpointsPath(root, slug)))?.n ?? null;
828
869
  const forward = steps.find((s) => !s.done && s.n !== (inFlight ?? landed));
829
- return driver ?? nextUpLine(forward, steps.length, relative(root, intentPath(root, slug)));
870
+ return driver ?? nextUpLine(forward, relative(root, intentPath(root, slug)));
830
871
  }
831
872
  /**
832
873
  * The Verdict for a step that just landed: the assembled rows folded worst-of
@@ -919,21 +960,30 @@ function summaryBlock(summary) {
919
960
  // named it too would put the step on the card twice.
920
961
  const READOUT_LABEL = '**Readout**:';
921
962
  /**
922
- * A step named the way every pointer names it: the number, the count out of
923
- * the whole plan, and the title behind a plain hyphen.
963
+ * A step named the way every pointer names it: the number in bold, the model
964
+ * its `- model:` line recommends, and the title behind a plain hyphen
965
+ * (`**Step 2** on Opus - <title>`).
924
966
  *
925
- * The count is the plan's own, step number over total, so the Now line and
926
- * Next Up agree under a re-sequenced build order. It is dropped only where it
927
- * would lie: a step the plan no longer holds is its number alone, since
928
- * "Step 9 of 3" would be worse than no count.
967
+ * The model rides the step it is for, so a card naming two steps and a range
968
+ * never leaves the human asking which one a model belongs to
969
+ * (D115 (card-weights)). A step with no recommendation, and a step the plan
970
+ * no longer holds, are their number alone.
929
971
  */
930
972
  function stepPhrase(n, steps) {
931
973
  const step = steps.find((s) => s.n === n);
932
974
  if (step === undefined) {
933
- return `Step ${n}`;
975
+ return `**Step ${n}**`;
934
976
  }
935
977
  const title = step.title.length > 0 ? ` - ${step.title}` : '';
936
- return `Step ${n} of ${steps.length}${title}`;
978
+ return `**Step ${n}**${onModel(step.model)}${title}`;
979
+ }
980
+ /**
981
+ * The clause naming the model a step or a range is for, ` on Sonnet`, read
982
+ * from its `- model:` line, or '' where none is written.
983
+ */
984
+ function onModel(model) {
985
+ const token = modelToken(model);
986
+ return token === null ? '' : ` on ${capitalize(token)}`;
937
987
  }
938
988
  /**
939
989
  * What the `spent` row is rendered from: the step's own stamps and counters
@@ -1092,23 +1142,116 @@ function callLine(move, outcome) {
1092
1142
  * When that step sits in a range the run can walk, `looks good` names the run
1093
1143
  * instead, since the run is what the human types next; a step in no range, or
1094
1144
  * in a flagged one, keeps the build.
1145
+ *
1146
+ * Until the plan commit lands, `looks good` also says what that commit
1147
+ * publishes, and a tracked folder adds the way out, `keep it local`, so the
1148
+ * note rides the card where the decision is made and no model composes it
1149
+ * (D117 (publish-on-the-card)).
1150
+ *
1151
+ * While a question waits on the human, two moves settle it: `yes to all leans`
1152
+ * takes the table's leans, and `defer Q3 until …` leaves one to the work. A
1153
+ * plan whose open questions are all deferred offers neither, since nothing
1154
+ * there is a call to make now (D120 (deferred-questions)).
1095
1155
  */
1096
- function planCallBlock(first, run) {
1156
+ function planCallBlock(first, run, publish, toAnswer) {
1097
1157
  const build = first === undefined ? '' : `; /plumbbob:build starts Step ${first.n}`;
1098
1158
  const starts = run.ok ? `; /plumbbob:run runs range ${run.range.item} from Step ${run.left[0]}` : build;
1099
- return [
1100
- '**Your Call**:',
1101
- '',
1102
- callLine('`looks good`', `I mark the plan decided${starts}`),
1159
+ const commits = publish === null
1160
+ ? ''
1161
+ : publish.kind === 'tracked'
1162
+ ? ` and commit it, so \`${publish.folder}\` rides this branch into the PR`
1163
+ : ' and commit it record-only (git ignores `.plumbbob/` here, so the commit message carries the plan)';
1164
+ const rows = [
1165
+ callLine('`looks good`', `I mark the plan decided${commits}${starts}`),
1166
+ ...(toAnswer
1167
+ ? [
1168
+ callLine('`yes to all leans`, or `yes to all leans except Q3: <your answer>`', 'I resolve each question in the table on its opener line, record the decision it settles, and show this card again'),
1169
+ callLine('`defer Q3 until <what settles it>`', 'I mark Q3 deferred on its opener line, with what will settle it, and show this card again'),
1170
+ ]
1171
+ : []),
1103
1172
  callLine('`expand`, or any question', 'I show more of what is there; nothing changes'),
1104
1173
  callLine('anything that reads as direction', 'I take it as what to sharpen; the plan is cheap to change now'),
1105
- ].join('\n');
1174
+ ];
1175
+ if (publish?.kind === 'tracked') {
1176
+ rows.push(callLine('`keep it local`', 'I run `plumbbob start --local`, so the plan commits record-only and the folder stays off the branch'));
1177
+ }
1178
+ return ['**Your Call**:', '', ...rows].join('\n');
1179
+ }
1180
+ /**
1181
+ * The open questions as one table, a row per question: the handle and slug
1182
+ * linked to the opener in `intent.md`, the question, why it matters, and the
1183
+ * lean, each clipped to its first sentence so a row stays one line and the
1184
+ * whole set fits on a screen (D118 (questions-table)). The expanded entry in
1185
+ * `intent.md` stays what a cold reader needs; this is the chat-side view of
1186
+ * it. A gap reads as one, the way the lean table's do: `not given` with no
1187
+ * why, `no lean proposed` with no lean. Null with no question open.
1188
+ *
1189
+ * The table holds only the questions that wait on the human. A question
1190
+ * deferred to the work rides one `**Deferred**:` line beneath it, linked to
1191
+ * its opener with what will settle it, and with every open question deferred
1192
+ * that line stands alone (D120 (deferred-questions)).
1193
+ *
1194
+ * ```text
1195
+ * **Open questions**: **1** to answer · 1 deferred
1196
+ *
1197
+ * | Question | Why it matters | Lean |
1198
+ * | --- | --- | --- |
1199
+ * | [**Q1** (round-lines)](…/intent.md#L54): Where do the round lines go? | Two rounds run together | The reply |
1200
+ *
1201
+ * **Deferred**: [**Q2** (latency-budget)](…/intent.md#L58) until step 4's latency numbers
1202
+ * ```
1203
+ */
1204
+ export function questionsPart(rows, where) {
1205
+ if (rows.length === 0) {
1206
+ return null;
1207
+ }
1208
+ const toAnswer = rows.filter((q) => q.deferred === null);
1209
+ const deferred = rows.filter((q) => q.deferred !== null);
1210
+ const body = toAnswer.map((q) => {
1211
+ const question = `[${questionHandle(q)}](${where}#L${q.line}): ${firstSentence(q.question)}`;
1212
+ const why = q.why === null || q.why.length === 0 ? 'not given' : firstSentence(q.why);
1213
+ const lean = q.lean === null || q.lean.length === 0 ? 'no lean proposed' : firstSentence(q.lean);
1214
+ return `| ${cell(question)} | ${cell(why)} | ${cell(lean)} |`;
1215
+ });
1216
+ const table = toAnswer.length === 0
1217
+ ? []
1218
+ : [`**Open questions**: ${questionsValue(questionTotals(rows))}`, '', QUESTIONS_TABLE_HEAD, '| --- | --- | --- |', ...body];
1219
+ const settles = (q) => (q.deferred === null || q.deferred.length === 0 ? 'until (not given)' : firstSentence(q.deferred));
1220
+ const line = deferred.length === 0 ? [] : [`**Deferred**: ${deferred.map((q) => `[${questionHandle(q)}](${where}#L${q.line}) ${settles(q)}`).join(' · ')}`];
1221
+ return [table.join('\n'), ...line].filter((part) => part.length > 0).join('\n\n');
1222
+ }
1223
+ /** A question's handle as the table and the Deferred line show it: the id bold, then its slug. */
1224
+ function questionHandle(q) {
1225
+ return q.slug === null ? `**${q.id}**` : `**${q.id}** (${q.slug})`;
1226
+ }
1227
+ // The questions table's header row, the same on the plan card and under
1228
+ // `status --questions`.
1229
+ const QUESTIONS_TABLE_HEAD = '| Question | Why it matters | Lean |';
1230
+ /**
1231
+ * A field's first sentence, without its closing period: a cell reads as a
1232
+ * phrase, and a question keeps its question mark. The whole text when no
1233
+ * sentence ends inside it.
1234
+ */
1235
+ function firstSentence(text) {
1236
+ const sentence = /^(.+?[.?!])(?=\s|$)/.exec(text.trim())?.[1] ?? text.trim();
1237
+ return sentence.replace(/\.$/, '');
1238
+ }
1239
+ /**
1240
+ * Read what the plan commit will publish through the same probe `checkpoint
1241
+ * --plan` stages by, so the card's promise and the commit cannot disagree
1242
+ * (D117 (publish-on-the-card)).
1243
+ */
1244
+ function planPublish(root, slug) {
1245
+ if (planCommitted(root, slug)) {
1246
+ return null;
1247
+ }
1248
+ return buildFolderIgnored(root, slug) ? { kind: 'record-only' } : { kind: 'tracked', folder: `${relative(root, buildFolder(root, slug))}/` };
1106
1249
  }
1107
1250
  /**
1108
1251
  * The driver turn's pointer: back to the step still in flight, since a park or
1109
- * a spike interrupts a step without ending it. No model clause rides here; the
1110
- * step is already being built, so the `/model` call is behind us. Null when no
1111
- * step is open, which is the caller's cue to fall back to the forward pointer.
1252
+ * a spike interrupts a step without ending it, named as the Now row names it.
1253
+ * Null when no step is open, which is the caller's cue to fall back to the
1254
+ * forward pointer.
1112
1255
  */
1113
1256
  function driverNextUpLine(steps, inFlight) {
1114
1257
  return inFlight === null ? null : `**Next Up**: Back to ${stepPhrase(inFlight, steps)}`;
@@ -1134,8 +1277,9 @@ const ORDER_CAP = 5;
1134
1277
  * something: the Verdict, the step in flight, the Stop, Next Up, the range the
1135
1278
  * next run walks, the build order where `## Build order` re-sequences the
1136
1279
  * 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)).
1280
+ * plan still carries, split by who acts on them. The first row present
1281
+ * becomes the header, which is the Verdict wherever one was measured
1282
+ * (D111 (state-table)).
1139
1283
  *
1140
1284
  * The Range, Order, and Parked rows are the segments the note beneath Next Up
1141
1285
  * used to carry on one indented line; as rows each one has a label of its own,
@@ -1143,64 +1287,81 @@ const ORDER_CAP = 5;
1143
1287
  */
1144
1288
  function stateRows(input) {
1145
1289
  const steps = parseOrderedSteps(input.intent);
1146
- const range = rangeSegment(input.intent, steps, input.nextUp, input.exclude);
1290
+ const range = rangeSegment(input.intent, steps, input.exclude);
1147
1291
  const order = buildOrderPhrase(remainingOrder(parseSteps(input.intent), parseBuildOrder(input.intent), input.exclude), ORDER_CAP);
1148
1292
  const parks = parseParkTotals(input.buildLog);
1149
- const questions = parseOpenQuestions(input.intent);
1293
+ const questions = questionTotals(parseOpenQuestionRows(input.intent));
1150
1294
  const rows = [
1151
1295
  input.verdict === null ? null : { label: 'Verdict', value: input.verdict },
1152
1296
  input.now === null ? null : { label: 'Now', value: input.now },
1153
1297
  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 },
1298
+ { label: 'Next Up', value: pointerText(input.nextUp, input.where) },
1299
+ range.length === 0 ? null : { label: 'Range', value: range },
1156
1300
  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` },
1301
+ parks.total === 0 ? null : { label: 'Parked', value: parkedValue(parks, input.now !== null) },
1302
+ questions.toAnswer + questions.deferred === 0 || input.questions === false ? null : { label: 'Questions', value: questionsValue(questions) },
1159
1303
  ];
1160
1304
  return rows.filter((r) => r !== null);
1161
1305
  }
1306
+ /**
1307
+ * The Questions row's fact, split by who acts: the questions that wait on the
1308
+ * human, bold, as the Parked row bolds what the human acts on, then the ones
1309
+ * deferred to the work (`**1** to answer · 2 deferred`), either half alone when
1310
+ * the other is zero (D120 (deferred-questions)). The caller drops the row at
1311
+ * zero.
1312
+ */
1313
+ function questionsValue(totals) {
1314
+ return [...(totals.toAnswer > 0 ? [`**${totals.toAnswer}** to answer`] : []), ...(totals.deferred > 0 ? [`${totals.deferred} deferred`] : [])].join(' · ');
1315
+ }
1316
+ /**
1317
+ * The Parked row's fact: the parks still open, bold, over every park ever
1318
+ * made, and while any is open the move that settles them, so a human who never
1319
+ * read the docs learns the verb where the count is (D115 (card-weights)).
1320
+ * Mid-step the move waits for the step to land, since a harvest runs only
1321
+ * between steps.
1322
+ */
1323
+ function parkedValue(parks, midStep) {
1324
+ const count = `**${parks.open}** of ${parks.total} open`;
1325
+ if (parks.open === 0) {
1326
+ return count;
1327
+ }
1328
+ return midStep ? `${count}: /plumbbob:harvest once this step lands` : `${count}: /plumbbob:harvest`;
1329
+ }
1162
1330
  /**
1163
1331
  * 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.
1332
+ * the Next Up line the driver tier keeps, written as the table's Next Up row
1333
+ * writes it.
1166
1334
  */
1167
- function pointerLine(intent, nextUp, exclude, where) {
1168
- const steps = parseOrderedSteps(intent);
1169
- const range = rangeSegment(intent, steps, nextUp, exclude);
1170
- return nextUpLine(nextUp, steps.length, where, range.model ?? nextUp?.model ?? null);
1335
+ function pointerLine(nextUp, where) {
1336
+ return nextUpLine(nextUp, where);
1171
1337
  }
1172
1338
  /**
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.
1339
+ * The Range row's fact: the run `/plumbbob:run` typed now would walk,
1340
+ * `**3** on Opus: /plumbbob:run (steps 6-9)`, the range's number bold and the
1341
+ * model its `- model:` line names beside it, so the model reads as the
1342
+ * range's and not the step's (D115 (card-weights)); the re-cut words for a
1343
+ * flagged range; the words for a step no range names; or '' for a plan
1344
+ * without ranges. The row's label says `Range`, so the fact opens on the
1345
+ * range's number.
1179
1346
  *
1180
1347
  * The bracket is the grant the hook would mint. At a step pause that is the
1181
1348
  * range holding the step in flight, its bracket opening on that step, since a
1182
1349
  * run typed there lands it first; at the pause on a range's last step, where
1183
- * a run ends on that range's stop, the segment names no run
1184
- * (`range 3 of 5 lands with step 9`). The model is read with the step the turn
1185
- * is about landed, so at that last-step pause it is the next range's, the one
1186
- * the human picks a model for. A flagged range, and a range with no
1187
- * `- model:` line, leave the step's model in the bracket.
1350
+ * a run ends on that range's stop, the segment names no run and no model
1351
+ * (`**3** lands with step 9`).
1188
1352
  */
1189
- function rangeSegment(intent, steps, nextUp, exclude) {
1190
- const landed = steps.map((s) => (s.n === exclude ? { ...s, done: true } : s));
1191
- const { holding, command } = rangeStanding(parseBuildRanges(intent), landed, nextUp?.n ?? null);
1192
- const model = holding !== null && command !== null ? holding.model : null;
1353
+ function rangeSegment(intent, steps, exclude) {
1193
1354
  const paused = steps.some((s) => s.n === exclude && !s.done) ? exclude : null;
1194
1355
  const run = cardRun(intent, paused);
1195
1356
  if (run.ok) {
1196
- const name = `${run.range.item} of ${run.count}`;
1357
+ const name = `**${run.range.item}**`;
1197
1358
  const lands = paused !== null && run.left.length === 1;
1198
- return { segment: lands ? `${name} lands with step ${paused}` : `${name}: /plumbbob:run (${runBracket(run)})`, model };
1359
+ return lands ? `${name} lands with step ${paused}` : `${name}${onModel(run.range.model)}: /plumbbob:run (${runBracket(run)})`;
1199
1360
  }
1200
1361
  if (run.kind === 'uncovered') {
1201
- return { segment: run.words, model };
1362
+ return run.words;
1202
1363
  }
1203
- return { segment: run.kind === 'flagged' ? `${run.item} of ${run.count} needs a re-cut (${run.words})` : '', model };
1364
+ return run.kind === 'flagged' ? `**${run.item}** needs a re-cut (${run.words})` : '';
1204
1365
  }
1205
1366
  /**
1206
1367
  * The run `/plumbbob:run` typed now would walk, as the card names it: the
@@ -1212,33 +1373,29 @@ function cardRun(intent, paused) {
1212
1373
  return resolveRun({ intent, inFlight: paused, spiking: false, item: null });
1213
1374
  }
1214
1375
  /**
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
1376
+ * The forward pointer's fact: the next undone step, the model it is for, its
1377
+ * title, and a closing bracket saying where to read it in full
1378
+ * (`**Step 3** on Sonnet - <title> (details: …)`). No next step ⇒ the
1218
1379
  * finish/step nudge instead.
1219
1380
  *
1220
- * The model is the second bold token the line spends, because it is the one
1221
- * the human acts on (a `/model` call) before the next run; the path is the
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.
1381
+ * The model rides the step it is for, the one the human acts on (a `/model`
1382
+ * call) before building it (D115 (card-weights)); the path is the short link
1383
+ * every `details:` bracket carries (`detailsLink`). The state table's Next Up
1384
+ * row carries it; `nextUpLine` labels it for the one line a driver turn ends on.
1225
1385
  */
1226
- function pointerText(nextUp, total, where, model = nextUp?.model ?? null) {
1386
+ function pointerText(nextUp, where) {
1227
1387
  if (nextUp === undefined) {
1228
1388
  return 'Nothing planned - /plumbbob:step or /plumbbob:finish';
1229
1389
  }
1230
1390
  const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
1231
- const token = modelToken(model);
1232
- const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
1233
- bracket.push(detailsLink(where, nextUp.line));
1234
- return `Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
1391
+ return `**Step ${nextUp.n}**${onModel(nextUp.model)}${title} (${detailsLink(where, nextUp.line)})`;
1235
1392
  }
1236
1393
  /**
1237
1394
  * The forward pointer as one labeled line: the driver tier's pointer, and the
1238
1395
  * line a boundary already shown ends on.
1239
1396
  */
1240
- function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
1241
- return `**Next Up**: ${pointerText(nextUp, total, where, model)}`;
1397
+ function nextUpLine(nextUp, where) {
1398
+ return `**Next Up**: ${pointerText(nextUp, where)}`;
1242
1399
  }
1243
1400
  /**
1244
1401
  * The model token from a `- model:` recommendation: the first word, so a
@@ -1253,7 +1410,7 @@ function modelToken(model) {
1253
1410
  }
1254
1411
  /**
1255
1412
  * Capitalize a token's first letter for the card's title-case furniture
1256
- * (`(model: Sonnet)`); the rest rides through unchanged.
1413
+ * (`on Sonnet`); the rest rides through unchanged.
1257
1414
  */
1258
1415
  function capitalize(s) {
1259
1416
  return s.length === 0 ? s : `${s[0]?.toUpperCase()}${s.slice(1)}`;
@@ -99,7 +99,7 @@ export function harvest(cwd, _args) {
99
99
  const counted = classWords(settled);
100
100
  const lead = stop === null
101
101
  ? transition({ label: 'Recorded', fact: `${settled.length} parked idea${settled.length === 1 ? '' : 's'}`, detail: [counted] })
102
- : transition({ label: 'Stop', fact: `Range ${stop.item} of ${stop.count} recorded`, detail: [counted] });
102
+ : transition({ label: 'Stop', fact: `**Range ${stop.item}** recorded`, detail: [counted] });
103
103
  printEnding(root, {
104
104
  tier: 'orientation',
105
105
  render: 'harvest',
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import { findRepoRoot } from "../lib/git.js";
21
21
  import { parseBuildOrder, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding } from "../lib/orient.js";
22
- import { activeBuild, checkpointsPath, clearHandoff, clearTick, grantPath, handoffPath, hasSession, intentPath, inSpike, listBuilds, readTurn, setActiveBuild, setGrant, stepPath, tickPath, } from "../lib/sidecar.js";
22
+ import { activeBuild, clearHandoff, clearTick, grantPath, handoffPath, hasSession, intentPath, inSpike, listBuilds, planCommitted, readTurn, setActiveBuild, setGrant, stepPath, tickPath, } from "../lib/sidecar.js";
23
23
  import { spikeBranches, spikeWorktrees } from "./spike.js";
24
24
  /**
25
25
  * Report every inconsistency in the control plane, and with `--fix` repair the
@@ -255,7 +255,7 @@ function leftoverFindings(root, slug, stepInFlight) {
255
255
  // pause, and cleared by the checkpoint that lands either one. At the boundary
256
256
  // with the plan already committed, neither pause is open, so a TICK left here
257
257
  // is measuring a span that closed.
258
- const planLanded = readOr(checkpointsPath(root, slug)).split('\n').some((l) => l.startsWith('plan '));
258
+ const planLanded = planCommitted(root, slug);
259
259
  if (!stepInFlight && planLanded && existsSync(tickPath(root, slug))) {
260
260
  findings.push({
261
261
  name: 'latch tick',