plumbbob 0.13.6 → 0.13.8

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, markStepDone, 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,73 +1277,112 @@ 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
- * 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.
1284
+ * The Order and Parked rows are segments the note beneath Next Up used to
1285
+ * carry on one indented line; as rows each one has a label of its own, the way
1286
+ * the human scans for it. The range is Next Up's second line, a row with a
1287
+ * blank label, so it reads as the next range beside the next step rather than
1288
+ * as the range the build is in (D122 (next-range-under-next-up)).
1143
1289
  */
1144
1290
  function stateRows(input) {
1145
1291
  const steps = parseOrderedSteps(input.intent);
1146
- const range = rangeSegment(input.intent, steps, input.nextUp, input.exclude);
1292
+ const range = rangeSegment(input.intent, steps, input.exclude);
1147
1293
  const order = buildOrderPhrase(remainingOrder(parseSteps(input.intent), parseBuildOrder(input.intent), input.exclude), ORDER_CAP);
1148
1294
  const parks = parseParkTotals(input.buildLog);
1149
- const questions = parseOpenQuestions(input.intent);
1295
+ const questions = questionTotals(parseOpenQuestionRows(input.intent));
1150
1296
  const rows = [
1151
1297
  input.verdict === null ? null : { label: 'Verdict', value: input.verdict },
1152
1298
  input.now === null ? null : { label: 'Now', value: input.now },
1153
1299
  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 },
1300
+ { label: 'Next Up', value: pointerText(input.nextUp, input.where) },
1301
+ range.length === 0 ? null : { label: '', value: range },
1156
1302
  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` },
1303
+ parks.total === 0 ? null : { label: 'Parked', value: parkedValue(parks, input.now !== null) },
1304
+ questions.toAnswer + questions.deferred === 0 || input.questions === false ? null : { label: 'Questions', value: questionsValue(questions) },
1159
1305
  ];
1160
1306
  return rows.filter((r) => r !== null);
1161
1307
  }
1308
+ /**
1309
+ * The Questions row's fact, split by who acts: the questions that wait on the
1310
+ * human, bold, as the Parked row bolds what the human acts on, then the ones
1311
+ * deferred to the work (`**1** to answer · 2 deferred`), either half alone when
1312
+ * the other is zero (D120 (deferred-questions)). The caller drops the row at
1313
+ * zero.
1314
+ */
1315
+ function questionsValue(totals) {
1316
+ return [...(totals.toAnswer > 0 ? [`**${totals.toAnswer}** to answer`] : []), ...(totals.deferred > 0 ? [`${totals.deferred} deferred`] : [])].join(' · ');
1317
+ }
1318
+ /**
1319
+ * The Parked row's fact: the parks still open, bold, over every park ever
1320
+ * made, and while any is open the move that settles them, so a human who never
1321
+ * read the docs learns the verb where the count is (D115 (card-weights)).
1322
+ * Mid-step the move waits for the step to land, since a harvest runs only
1323
+ * between steps.
1324
+ */
1325
+ function parkedValue(parks, midStep) {
1326
+ const count = `**${parks.open}** of ${parks.total} open`;
1327
+ if (parks.open === 0) {
1328
+ return count;
1329
+ }
1330
+ return midStep ? `${count}: /plumbbob:harvest once this step lands` : `${count}: /plumbbob:harvest`;
1331
+ }
1162
1332
  /**
1163
1333
  * 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.
1334
+ * the Next Up line the driver tier keeps, written as the table's Next Up row
1335
+ * writes it.
1166
1336
  */
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);
1337
+ function pointerLine(nextUp, where) {
1338
+ return nextUpLine(nextUp, where);
1171
1339
  }
1172
1340
  /**
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.
1341
+ * Next Up's range line: the range the next run walks,
1342
+ * `**Range 3** on Opus: /plumbbob:run (steps 6-9)`, its name bold and the
1343
+ * model its `- model:` line names beside it, so the model reads as the
1344
+ * range's and not the step's (D115 (card-weights)); the re-cut words for a
1345
+ * flagged range; the words for a step no range names; or '' for a plan
1346
+ * without ranges. The row has no label of its own, so the fact opens on the
1347
+ * range's name (D122 (next-range-under-next-up)).
1179
1348
  *
1180
1349
  * The bracket is the grant the hook would mint. At a step pause that is the
1181
1350
  * range holding the step in flight, its bracket opening on that step, since a
1182
- * 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.
1188
- */
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;
1351
+ * run typed there lands it first. At the pause on a range's last step, or a
1352
+ * bare range's, a run lands only that step and ends on its range's stop, so
1353
+ * the line names the range the step after it opens instead
1354
+ * (`**Range 4** on Fable: /plumbbob:run once step 9 lands`).
1355
+ */
1356
+ function rangeSegment(intent, steps, exclude) {
1193
1357
  const paused = steps.some((s) => s.n === exclude && !s.done) ? exclude : null;
1194
1358
  const run = cardRun(intent, paused);
1359
+ if (run.ok && paused !== null && run.left.length === 1) {
1360
+ return afterLanding(intent, paused);
1361
+ }
1362
+ return runSegment(run, (r) => `(${runBracket(r)})`);
1363
+ }
1364
+ /**
1365
+ * The range line at the pause that finishes a range: the run the boundary will
1366
+ * name once the step in flight lands, read from the plan with that step
1367
+ * ticked, and opening once it does. '' when no step is left after it.
1368
+ */
1369
+ function afterLanding(intent, paused) {
1370
+ const next = cardRun(markStepDone(intent, paused), null);
1371
+ return runSegment(next, () => `once step ${paused} lands`);
1372
+ }
1373
+ /**
1374
+ * A run as the range line names it: the range's name, its model, and the run
1375
+ * with `when` closing it; or, where the run would refuse, the words for a step
1376
+ * no range names or a flagged range's re-cut, and '' for any other refusal.
1377
+ */
1378
+ function runSegment(run, when) {
1195
1379
  if (run.ok) {
1196
- const name = `${run.range.item} of ${run.count}`;
1197
- const lands = paused !== null && run.left.length === 1;
1198
- return { segment: lands ? `${name} lands with step ${paused}` : `${name}: /plumbbob:run (${runBracket(run)})`, model };
1380
+ return `**Range ${run.range.item}**${onModel(run.range.model)}: /plumbbob:run ${when(run)}`;
1199
1381
  }
1200
1382
  if (run.kind === 'uncovered') {
1201
- return { segment: run.words, model };
1383
+ return run.words;
1202
1384
  }
1203
- return { segment: run.kind === 'flagged' ? `${run.item} of ${run.count} needs a re-cut (${run.words})` : '', model };
1385
+ return run.kind === 'flagged' ? `**Range ${run.item}** needs a re-cut (${run.words})` : '';
1204
1386
  }
1205
1387
  /**
1206
1388
  * The run `/plumbbob:run` typed now would walk, as the card names it: the
@@ -1212,33 +1394,29 @@ function cardRun(intent, paused) {
1212
1394
  return resolveRun({ intent, inFlight: paused, spiking: false, item: null });
1213
1395
  }
1214
1396
  /**
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
1397
+ * The forward pointer's fact: the next undone step, the model it is for, its
1398
+ * title, and a closing bracket saying where to read it in full
1399
+ * (`**Step 3** on Sonnet - <title> (details: …)`). No next step ⇒ the
1218
1400
  * finish/step nudge instead.
1219
1401
  *
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.
1402
+ * The model rides the step it is for, the one the human acts on (a `/model`
1403
+ * call) before building it (D115 (card-weights)); the path is the short link
1404
+ * every `details:` bracket carries (`detailsLink`). The state table's Next Up
1405
+ * row carries it; `nextUpLine` labels it for the one line a driver turn ends on.
1225
1406
  */
1226
- function pointerText(nextUp, total, where, model = nextUp?.model ?? null) {
1407
+ function pointerText(nextUp, where) {
1227
1408
  if (nextUp === undefined) {
1228
1409
  return 'Nothing planned - /plumbbob:step or /plumbbob:finish';
1229
1410
  }
1230
1411
  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(', ')})`;
1412
+ return `**Step ${nextUp.n}**${onModel(nextUp.model)}${title} (${detailsLink(where, nextUp.line)})`;
1235
1413
  }
1236
1414
  /**
1237
1415
  * The forward pointer as one labeled line: the driver tier's pointer, and the
1238
1416
  * line a boundary already shown ends on.
1239
1417
  */
1240
- function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
1241
- return `**Next Up**: ${pointerText(nextUp, total, where, model)}`;
1418
+ function nextUpLine(nextUp, where) {
1419
+ return `**Next Up**: ${pointerText(nextUp, where)}`;
1242
1420
  }
1243
1421
  /**
1244
1422
  * The model token from a `- model:` recommendation: the first word, so a
@@ -1253,7 +1431,7 @@ function modelToken(model) {
1253
1431
  }
1254
1432
  /**
1255
1433
  * Capitalize a token's first letter for the card's title-case furniture
1256
- * (`(model: Sonnet)`); the rest rides through unchanged.
1434
+ * (`on Sonnet`); the rest rides through unchanged.
1257
1435
  */
1258
1436
  function capitalize(s) {
1259
1437
  return s.length === 0 ? s : `${s[0]?.toUpperCase()}${s.slice(1)}`;
@@ -25,10 +25,11 @@ import { findRepoRoot } from "../lib/git.js";
25
25
  import { ending, notice, printEnding, transition } from "../lib/notice.js";
26
26
  import { appendHarvest, appendToSection, flipParks, harvestLines, logEntry, stopLogLine } from "../lib/buildlog.js";
27
27
  import { checkStopLatch } from "../lib/latch.js";
28
- import { parseBuildRanges, parseDetailStep, parseLastCheckpoint, parseOrderedSteps, stepShas } from "../lib/orient.js";
28
+ import { parseBuildRanges, parseDetailStep, parseLastCheckpoint, parseOpenQuestionRows, parseOrderedSteps, stepShas } from "../lib/orient.js";
29
29
  import { activeBuild, buildLogPath, checkpointsPath, clearDetail, clearStop, detailPath, hasSession, intentPath, readStop, } from "../lib/sidecar.js";
30
30
  import { hasStep } from "../lib/steplist.js";
31
31
  import { boundaryEnding, harvestProposals, isHarvestDetail, parkList, parseDetailRange, stopRecord, } from "./handoff.js";
32
+ import { setAsideAdvisories } from "./defer.js";
32
33
  // The three classes a park settles as, in the order the ending counts them.
33
34
  const CLASSES = ['blocker', 'tangent', 'pivot'];
34
35
  // Where a refusal about the proposals sends the model: the file it wrote them in.
@@ -99,11 +100,22 @@ export function harvest(cwd, _args) {
99
100
  const counted = classWords(settled);
100
101
  const lead = stop === null
101
102
  ? 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] });
103
+ : transition({ label: 'Stop', fact: `**Range ${stop.item}** recorded`, detail: [counted] });
104
+ // A tangent naming a question that still waits on the human has set it
105
+ // aside without deferring it, and the card would go on counting it as a
106
+ // call owed (D123 (defer-records)).
107
+ const tangents = settled
108
+ .filter((p) => p.kind === 'tangent')
109
+ .map((p) => `${p.text} ${p.reason}`)
110
+ .join(' ');
103
111
  printEnding(root, {
104
112
  tier: 'orientation',
105
113
  render: 'harvest',
106
- text: ending({ lead, state: boundaryEnding(root, slug, null).state }),
114
+ text: ending({
115
+ lead,
116
+ advisories: setAsideAdvisories(tangents, parseOpenQuestionRows(intent)),
117
+ state: boundaryEnding(root, slug, null).state,
118
+ }),
107
119
  });
108
120
  return 0;
109
121
  }