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