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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -6
- package/dist/cli-core.js +15 -1
- package/dist/lib/links.js +97 -0
- package/dist/lib/notice.js +1 -1
- package/dist/lib/orient.js +152 -11
- package/dist/lib/sidecar.js +43 -5
- package/dist/verbs/build.js +20 -12
- package/dist/verbs/checkpoint.js +8 -4
- package/dist/verbs/defer.js +96 -0
- package/dist/verbs/handoff.js +275 -97
- package/dist/verbs/harvest.js +15 -3
- package/dist/verbs/park.js +14 -4
- package/dist/verbs/recover.js +2 -2
- package/dist/verbs/relay.js +74 -6
- package/dist/verbs/start.js +40 -14
- package/dist/verbs/status.js +16 -4
- package/dist/verbs/turn.js +29 -12
- package/package.json +1 -1
- package/skills/build/SKILL.md +37 -19
- package/skills/harvest/SKILL.md +12 -3
- package/skills/park/SKILL.md +26 -9
- package/skills/plan/SKILL.md +43 -16
- package/skills/refine/SKILL.md +19 -5
- package/skills/run/SKILL.md +24 -10
- package/skills/status/SKILL.md +7 -0
- package/skills/verify/SKILL.md +12 -7
- package/templates/intent.md +19 -1
package/dist/verbs/handoff.js
CHANGED
|
@@ -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 {
|
|
53
|
-
import {
|
|
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
|
-
|
|
129
|
-
|
|
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(
|
|
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
|
|
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
|
|
336
|
-
* whatever changed since (a lean the human changed, a park, a gate
|
|
337
|
-
* `--full` prints the card again.
|
|
338
|
-
*
|
|
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,
|
|
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
|
-
|
|
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}
|
|
383
|
+
`**Next Up**: Back to **Range ${range.item}**'s stop - ${range.stop}`,
|
|
365
384
|
]);
|
|
366
385
|
}
|
|
367
|
-
writeStop(root, slug, { range: range.item, turn
|
|
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
|
|
374
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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**:
|
|
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}
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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
|
|
923
|
-
*
|
|
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
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
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
|
|
975
|
+
return `**Step ${n}**`;
|
|
934
976
|
}
|
|
935
977
|
const title = step.title.length > 0 ? ` - ${step.title}` : '';
|
|
936
|
-
return
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
''
|
|
1102
|
-
|
|
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
|
-
]
|
|
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
|
|
1110
|
-
* step is
|
|
1111
|
-
*
|
|
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
|
|
1138
|
-
* Verdict wherever one was measured
|
|
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
|
|
1141
|
-
*
|
|
1142
|
-
* the
|
|
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.
|
|
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 =
|
|
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,
|
|
1155
|
-
range.
|
|
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:
|
|
1158
|
-
questions === 0 ? null : { label: 'Questions', value:
|
|
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,
|
|
1165
|
-
*
|
|
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(
|
|
1168
|
-
|
|
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
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
* the step
|
|
1177
|
-
* a step no range names; or '' for a plan
|
|
1178
|
-
*
|
|
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
|
|
1183
|
-
* a run ends on
|
|
1184
|
-
*
|
|
1185
|
-
*
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
-
|
|
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
|
|
1383
|
+
return run.words;
|
|
1202
1384
|
}
|
|
1203
|
-
return
|
|
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
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
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
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
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,
|
|
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
|
-
|
|
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,
|
|
1241
|
-
return `**Next Up**: ${pointerText(nextUp,
|
|
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
|
-
* (`
|
|
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)}`;
|
package/dist/verbs/harvest.js
CHANGED
|
@@ -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:
|
|
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({
|
|
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
|
}
|