plumbbob 0.13.0 → 0.13.2
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 +75 -54
- package/dist/cli-core.js +29 -9
- package/dist/lib/buildlog.js +77 -0
- package/dist/lib/git.js +53 -0
- package/dist/lib/latch.js +46 -5
- package/dist/lib/notice.js +3 -2
- package/dist/lib/orient.js +878 -61
- package/dist/lib/sidecar.js +164 -3
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/build.js +108 -14
- package/dist/verbs/checkpoint.js +41 -13
- package/dist/verbs/handoff.js +508 -42
- package/dist/verbs/harvest.js +224 -0
- package/dist/verbs/order.js +15 -11
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/relay.js +15 -2
- package/dist/verbs/status.js +64 -17
- package/dist/verbs/turn.js +170 -58
- package/package.json +1 -1
- package/skills/build/SKILL.md +85 -34
- package/skills/finish/SKILL.md +18 -4
- package/skills/harvest/SKILL.md +54 -11
- package/skills/order/SKILL.md +20 -10
- package/skills/plan/SKILL.md +77 -17
- package/skills/refine/SKILL.md +24 -3
- package/skills/run/SKILL.md +256 -0
- package/skills/status/SKILL.md +10 -3
- package/skills/step/SKILL.md +25 -3
- package/skills/verify/SKILL.md +6 -2
- package/templates/intent.md +30 -8
package/dist/verbs/handoff.js
CHANGED
|
@@ -33,18 +33,22 @@
|
|
|
33
33
|
// Every tier's ending is emitted here, so no skill has to fake the furniture in
|
|
34
34
|
// prose: `--plan` renders the plan-pause ending and `--driver` the driver
|
|
35
35
|
// turn's pointer, the two endings the session state cannot tell apart from the
|
|
36
|
-
// ones above
|
|
36
|
+
// ones above, and `--stop` the stop card that closes a run's range, which reads
|
|
37
|
+
// a boundary the session state cannot tell from a plain one either. The stop
|
|
38
|
+
// card renders once too, under its own stamp in `STOP`, which also carries the
|
|
39
|
+
// turn it printed in for `plumbbob harvest`'s latch. The driver pointer reads the phase it lands in: a spike open over
|
|
37
40
|
// the step names closing the spike, a step exit (a revert, an abandon, a spike
|
|
38
41
|
// closed at the boundary) finds nothing in flight and points forward, with no
|
|
39
42
|
// Verdict either way, since nothing landed.
|
|
40
43
|
import { createHash } from 'node:crypto';
|
|
41
44
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
42
|
-
import { join, relative } from 'node:path';
|
|
43
|
-
import { PLAN_COMMIT_MARKER, commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
|
|
44
|
-
import { blocks, printEnding } from "../lib/notice.js";
|
|
45
|
+
import { basename, join, relative } from 'node:path';
|
|
46
|
+
import { PLAN_COMMIT_MARKER, commitNumstat, commitsBetween, commitsSince, diffNumstat, diffPatch, findRepoRoot, } from "../lib/git.js";
|
|
47
|
+
import { blocks, notice, printEnding, transition } from "../lib/notice.js";
|
|
48
|
+
import { hasStep } from "../lib/steplist.js";
|
|
45
49
|
import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
|
|
46
|
-
import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, pausePath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
|
|
47
|
-
import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
|
|
50
|
+
import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, pausePath, readStats, readStop, readTurn, resolveBuild, seamPath, stepPath, tickPath, writeStop, } from "../lib/sidecar.js";
|
|
51
|
+
import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, measureRange, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeLanded, rangeSpan, rangeStanding, recapLines, remainingOrder, resolveRun, runBracket, seamRowFromDiff, spentRowValue, stopFollowUp, summaryCheckRow, } from "../lib/orient.js";
|
|
48
52
|
// The inline-diff threshold (docs/presentation.md): at 20 changed lines or
|
|
49
53
|
// fewer the patch rides the turn; at 21 it stays in the working tree behind
|
|
50
54
|
// the counted diff row.
|
|
@@ -83,13 +87,17 @@ export function handoff(cwd, args = []) {
|
|
|
83
87
|
return 1;
|
|
84
88
|
}
|
|
85
89
|
const { build: slug, rest } = resolveBuild(root, args);
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
const tiers = ['--plan', '--driver', '--stop'].filter((flag) => rest.includes(flag));
|
|
91
|
+
if (tiers.length > 1) {
|
|
92
|
+
process.stderr.write(`plumbbob: handoff: ${tiers.join(' and ')} name different tiers; pass one.\n`);
|
|
88
93
|
return 1;
|
|
89
94
|
}
|
|
90
95
|
// A build named by flag rides any command that reprints this ending, since
|
|
91
96
|
// the cursor may point elsewhere.
|
|
92
97
|
const named = args.includes('--build') ? slug : null;
|
|
98
|
+
if (rest.includes('--stop')) {
|
|
99
|
+
return stopCard(root, slug, named, rest.includes('--full'));
|
|
100
|
+
}
|
|
93
101
|
const intent = readOr(intentPath(root, slug));
|
|
94
102
|
// In build order, so the pointer names the step a bare `plumbbob build`
|
|
95
103
|
// would enter; the build-log feeds the park count beneath it.
|
|
@@ -108,8 +116,8 @@ export function handoff(cwd, args = []) {
|
|
|
108
116
|
const detail = settleDetail(root, rawDetail);
|
|
109
117
|
const plan = [
|
|
110
118
|
SEAM_RULE,
|
|
111
|
-
nextUpBlock(
|
|
112
|
-
planCallBlock(first),
|
|
119
|
+
nextUpBlock(intent, buildLog, first, null, where),
|
|
120
|
+
planCallBlock(first, cardRun(intent, null)),
|
|
113
121
|
];
|
|
114
122
|
const command = reprintCommand('--plan', undefined, named);
|
|
115
123
|
return emit(root, { tier: 'decision', render: 'plan', command }, withRecommendation(plan, parseRecommendation(detail)));
|
|
@@ -137,7 +145,7 @@ export function handoff(cwd, args = []) {
|
|
|
137
145
|
if (current === null) {
|
|
138
146
|
// Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
|
|
139
147
|
return emit(root, { tier: 'orientation', render: 'pointer' }, [
|
|
140
|
-
nextUpBlock(
|
|
148
|
+
nextUpBlock(intent, buildLog, nextUp, current, where),
|
|
141
149
|
]);
|
|
142
150
|
}
|
|
143
151
|
// A step is pending, and its hand-off is the pause, while it is in flight.
|
|
@@ -147,11 +155,12 @@ export function handoff(cwd, args = []) {
|
|
|
147
155
|
// which pause to render.
|
|
148
156
|
const pending = inFlight !== null || (explicit !== undefined && steps.some((s) => s.n === current && !s.done));
|
|
149
157
|
if (!pending) {
|
|
150
|
-
// The boundary: the orientation-tier ending, the state word
|
|
151
|
-
//
|
|
152
|
-
// checkpoint printed for itself,
|
|
158
|
+
// The boundary: the orientation-tier ending, the state word, the Stop part
|
|
159
|
+
// where a range just landed, and the forward pointer, no decision pending
|
|
160
|
+
// and so no Your Call. The same parts the checkpoint printed for itself,
|
|
161
|
+
// from the same call.
|
|
153
162
|
const landed = boundaryEnding(root, slug, current);
|
|
154
|
-
return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.pointer]);
|
|
163
|
+
return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.stop, landed.pointer]);
|
|
155
164
|
}
|
|
156
165
|
// The pause: the whole turn as one contiguous block, each part a labeled line
|
|
157
166
|
// with one blank line between, and the only fence the readout's own (plus the
|
|
@@ -197,7 +206,7 @@ export function handoff(cwd, args = []) {
|
|
|
197
206
|
if (inline) {
|
|
198
207
|
parts.push(fence('diff', patch.split('\n')).join('\n'));
|
|
199
208
|
}
|
|
200
|
-
parts.push(verdict, nextUpBlock(
|
|
209
|
+
parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen, cardRun(intent, current)));
|
|
201
210
|
const command = reprintCommand('--full', explicit, named);
|
|
202
211
|
return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
|
|
203
212
|
}
|
|
@@ -267,25 +276,416 @@ function writePause(root, slug, stamp) {
|
|
|
267
276
|
}
|
|
268
277
|
}
|
|
269
278
|
/**
|
|
270
|
-
* The
|
|
271
|
-
* everything measurable right now,
|
|
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.
|
|
272
282
|
*
|
|
273
283
|
* `step` is the step that just landed, or null at a plan commit, where nothing
|
|
274
|
-
* was measured and the Verdict
|
|
275
|
-
* its own lead line, so a boundary reads identically
|
|
276
|
-
* emitted it or a later `handoff` did; the paths a stray
|
|
277
|
-
* one part that does not survive, and the commit holds
|
|
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.
|
|
278
289
|
*/
|
|
279
290
|
export function boundaryEnding(root, slug, step) {
|
|
280
291
|
const intent = readOr(intentPath(root, slug));
|
|
281
292
|
const steps = parseOrderedSteps(intent);
|
|
282
293
|
const nextUp = steps.find((s) => !s.done && s.n !== step);
|
|
283
|
-
const
|
|
294
|
+
const where = relative(root, intentPath(root, slug));
|
|
295
|
+
const landed = landedRange(intent, steps, step);
|
|
284
296
|
return {
|
|
285
297
|
verdict: step === null ? null : landedVerdict(root, slug, intent, step),
|
|
286
|
-
|
|
298
|
+
stop: landed === null ? null : stopLead(landed, where),
|
|
299
|
+
range: landed === null ? null : { item: landed.range.item, count: landed.count },
|
|
300
|
+
pointer: nextUpBlock(intent, readOr(buildLogPath(root, slug)), nextUp, step, where),
|
|
287
301
|
};
|
|
288
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* The range that lands its steps whose last undone step was `step`, now
|
|
305
|
+
* landed, or null for every other landing: one that leaves its range open, a
|
|
306
|
+
* bare range (its stop was its own pause), a step no range names, a plan
|
|
307
|
+
* without ranges, and the plan commit, where nothing landed.
|
|
308
|
+
*/
|
|
309
|
+
function landedRange(intent, steps, step) {
|
|
310
|
+
if (step === null) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const { count, completed } = rangeStanding(parseBuildRanges(intent), steps, step);
|
|
314
|
+
return completed === null || completed.bare ? null : { range: completed, count, step };
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The Stop part: why the build stops at this boundary. One labeled line, the
|
|
318
|
+
* range over the count, a plain hyphen, the stop as the plan wrote it, and a
|
|
319
|
+
* bracket pointing at the item, where its note can be read. The stop card
|
|
320
|
+
* opens on the same line, moved to the top of the card.
|
|
321
|
+
*/
|
|
322
|
+
function stopLead(landed, where) {
|
|
323
|
+
return transition({
|
|
324
|
+
label: 'Stop',
|
|
325
|
+
fact: `Range ${landed.range.item} of ${landed.count} landed - ${landed.range.stop}`,
|
|
326
|
+
detail: [detailsLink(where, landed.range.line)],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The `details:` clause every bracket on a card carries, in its short linked
|
|
331
|
+
* form: the file's name and line as the link's text, and its path from the
|
|
332
|
+
* repo root with a `#L41` fragment as the target. With no line, the name links
|
|
333
|
+
* the whole file, as the Summary lead's does, since the whole file is the
|
|
334
|
+
* detail.
|
|
335
|
+
*
|
|
336
|
+
* The full path was noise on every card while the name alone still says which
|
|
337
|
+
* file, and a relative target keeps any one machine's path out of a pasted
|
|
338
|
+
* card (D107 (short-linked-paths)).
|
|
339
|
+
*/
|
|
340
|
+
export function detailsLink(path, line) {
|
|
341
|
+
const name = basename(path);
|
|
342
|
+
return line === null ? `details: [${name}](${path})` : `details: [${name}:${line}](${path}#L${line})`;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The stop card that closes a run's range (docs/presentation.md): the Stop
|
|
346
|
+
* lead, the range's Summary and highlights from the range's detail file, the
|
|
347
|
+
* Readout measured across the range, the Verdict, the Harvested part while a
|
|
348
|
+
* park is open, Next Up, Your Call, and the recommendation.
|
|
349
|
+
*
|
|
350
|
+
* It renders for the range the last landing completed, and refuses with a
|
|
351
|
+
* step in flight, with no range just landed, and with a detail file written
|
|
352
|
+
* for anything but that range, since a card with none of the model's review
|
|
353
|
+
* on it would ask the human to approve nothing. It renders once per stop:
|
|
354
|
+
* while the STOP stamp matches the range and what the card showed, a later
|
|
355
|
+
* render is the one-line reminder, and `--full` prints the card whatever the
|
|
356
|
+
* stamp says. Every card it prints rewrites the stamp with the turn it printed
|
|
357
|
+
* in, which is the turn `plumbbob harvest` waits past.
|
|
358
|
+
*/
|
|
359
|
+
function stopCard(root, slug, named, full) {
|
|
360
|
+
const intent = readOr(intentPath(root, slug));
|
|
361
|
+
const steps = parseOrderedSteps(intent);
|
|
362
|
+
const landed = stopRange(root, slug, intent, steps);
|
|
363
|
+
if (typeof landed === 'string') {
|
|
364
|
+
process.stderr.write(landed);
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
const { range, count, step } = landed;
|
|
368
|
+
const detail = settleDetail(root, readOr(detailPath(root)));
|
|
369
|
+
if (parseDetailRange(detail) !== range.item) {
|
|
370
|
+
process.stderr.write(notice({
|
|
371
|
+
fact: 'no stop card to render',
|
|
372
|
+
detail: [`the detail file is not range ${range.item}'s`],
|
|
373
|
+
remedy: `write range ${range.item}'s detail in .plumbbob/detail.md, then plumbbob handoff --stop`,
|
|
374
|
+
}));
|
|
375
|
+
return 1;
|
|
376
|
+
}
|
|
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
|
+
const stamp = readStop(root, slug);
|
|
384
|
+
if (!full && stamp !== null && stamp.range === range.item && stamp.hash === hash) {
|
|
385
|
+
// The stop the human already read, unchanged: the answer above this line
|
|
386
|
+
// is what they asked for, and the card would push it off the screen.
|
|
387
|
+
const command = reprintCommand('--stop', undefined, named);
|
|
388
|
+
return emit(root, { tier: 'decision', render: 'stop-reminder', command }, [
|
|
389
|
+
`**Next Up**: Back to Range ${range.item} of ${count}'s stop - ${range.stop}`,
|
|
390
|
+
]);
|
|
391
|
+
}
|
|
392
|
+
writeStop(root, slug, { range: range.item, turn: readTurn(root), hash });
|
|
393
|
+
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
|
+
];
|
|
398
|
+
if (review.readout.length > 0) {
|
|
399
|
+
parts.push([READOUT_LABEL, '', ...fence('text', review.readout)].join('\n'));
|
|
400
|
+
}
|
|
401
|
+
parts.push(review.verdict);
|
|
402
|
+
if (harvest !== null) {
|
|
403
|
+
parts.push(harvest);
|
|
404
|
+
}
|
|
405
|
+
parts.push(
|
|
406
|
+
// The note carries no parks segment here: the Harvested lead above sizes the list.
|
|
407
|
+
nextUpBlock(intent, '', nextUp, step, where), stopCallBlock(leanExample(parks, harvestProposals(detail)), intent));
|
|
408
|
+
const command = reprintCommand('--stop --full', undefined, named);
|
|
409
|
+
return emit(root, { tier: 'decision', render: 'stop', command }, withRecommendation(parts, parseRecommendation(detail)));
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The range the stop card reviews: the one the last landing completed, or the
|
|
413
|
+
* refusal that says why no stop card renders, with the move that does render
|
|
414
|
+
* this moment.
|
|
415
|
+
*/
|
|
416
|
+
function stopRange(root, slug, intent, steps) {
|
|
417
|
+
const refuse = (why, remedy) => notice({ fact: 'no stop card to render', detail: [why], remedy });
|
|
418
|
+
const inFlight = readMarker(stepPath(root, slug));
|
|
419
|
+
if (inFlight !== null) {
|
|
420
|
+
return refuse(`step ${inFlight} is in flight`, 'plumbbob handoff renders its pause');
|
|
421
|
+
}
|
|
422
|
+
const last = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
|
|
423
|
+
if (last === null) {
|
|
424
|
+
return refuse('no step has landed', 'plumbbob handoff renders the pointer');
|
|
425
|
+
}
|
|
426
|
+
const landed = landedRange(intent, steps, last.n);
|
|
427
|
+
if (landed === null) {
|
|
428
|
+
const { completed, count } = rangeStanding(parseBuildRanges(intent), steps, last.n);
|
|
429
|
+
const why = completed === null
|
|
430
|
+
? `step ${last.n} landed last, and completed no range`
|
|
431
|
+
: `range ${completed.item} of ${count} is bare, and its stop was step ${last.n}'s pause`;
|
|
432
|
+
return refuse(why, 'plumbbob handoff renders this boundary');
|
|
433
|
+
}
|
|
434
|
+
return landed;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Measure a landed range and fold its Verdict: the CLI's rows across the
|
|
438
|
+
* range's commits, the ledger, stats.json, and the last check, joined with
|
|
439
|
+
* the three judgment rows the range's detail wrote, the measured ones winning
|
|
440
|
+
* where both exist.
|
|
441
|
+
*
|
|
442
|
+
* The card and the record read the review through this one call, so the Log
|
|
443
|
+
* keeps exactly what the card showed.
|
|
444
|
+
*/
|
|
445
|
+
function stopReview(root, slug, intent, range, detail) {
|
|
446
|
+
const measure = rangeMeasure(root, slug, intent, range);
|
|
447
|
+
const rows = { ...(parseRecap(detail, 'range')?.rows ?? {}), ...measure.rows };
|
|
448
|
+
const { ladder, worst } = foldVerdict(rows, measure.stats, 'range');
|
|
449
|
+
return {
|
|
450
|
+
readout: recapLines(rows, {
|
|
451
|
+
steps: measure.steps,
|
|
452
|
+
diff: measure.diff,
|
|
453
|
+
spent: measure.spent,
|
|
454
|
+
constraints: parseConstraintCount(intent),
|
|
455
|
+
}),
|
|
456
|
+
verdict: verdictLine(ladder, worst),
|
|
457
|
+
stamped: recapLines(measure.rows, { steps: measure.steps, diff: measure.diff, spent: null, constraints: 0 }),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Read what a landed range's measures are taken from: each checkpoint's
|
|
462
|
+
* commit the ledger names for a step of the range, the first-parent commits
|
|
463
|
+
* across the range's span for the out-of-band count, stats.json, and the last
|
|
464
|
+
* check's summary.
|
|
465
|
+
*/
|
|
466
|
+
function rangeMeasure(root, slug, intent, range) {
|
|
467
|
+
const checkpoints = readOr(checkpointsPath(root, slug));
|
|
468
|
+
const commits = {};
|
|
469
|
+
for (const line of checkpoints.split('\n')) {
|
|
470
|
+
const m = /^step\s+(\d+)\s+(\S+)/.exec(line.trim());
|
|
471
|
+
const sha = m?.[2];
|
|
472
|
+
if (sha !== undefined && hasStep(range.steps, Number(m?.[1]))) {
|
|
473
|
+
commits[sha] = commitNumstat(root, sha);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const span = rangeSpan(checkpoints, range);
|
|
477
|
+
return measureRange({
|
|
478
|
+
range,
|
|
479
|
+
intent,
|
|
480
|
+
checkpoints,
|
|
481
|
+
stats: readStats(root, slug),
|
|
482
|
+
commits,
|
|
483
|
+
between: span === null ? [] : commitsBetween(root, span.from, span.to, PLAN_COMMIT_MARKER),
|
|
484
|
+
summary: readCheckSummary(root),
|
|
485
|
+
});
|
|
486
|
+
}
|
|
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
|
+
/**
|
|
500
|
+
* The park list under `build-log.md`'s `## Park list`, each line with its
|
|
501
|
+
* handle: its place in the list, counting every line ever parked, harvested
|
|
502
|
+
* ones included.
|
|
503
|
+
*
|
|
504
|
+
* A line is never deleted from the list, only flipped, so the handle is
|
|
505
|
+
* stable for the life of the build, and it counts the same lines the note's
|
|
506
|
+
* `parked 3 of 8 open` does. The stop card prints the handle and the text
|
|
507
|
+
* from here, and `plumbbob harvest` matches a proposal to its park by the
|
|
508
|
+
* handle alone.
|
|
509
|
+
*/
|
|
510
|
+
export function parkList(buildLog) {
|
|
511
|
+
const lines = buildLog.split('\n');
|
|
512
|
+
const start = lines.findIndex((l) => l.trim() === '## Park list');
|
|
513
|
+
if (start === -1) {
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
const end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
|
|
517
|
+
return lines.slice(start + 1, end === -1 ? lines.length : end).flatMap((line) => {
|
|
518
|
+
const m = /^-\s+\[([ xX])\]\s+(\S.*)$/.exec(line.trim());
|
|
519
|
+
return m === null ? [] : [{ open: m[1] === ' ', text: (m[2] ?? '').trim() }];
|
|
520
|
+
}).map((park, i) => ({ handle: i + 1, ...park }));
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* The proposals under a detail file's `## Harvest` section, one per
|
|
524
|
+
* `- P6 → tangent: <the reason>` line, in the order written. The handle is the
|
|
525
|
+
* one the card prints (D105 (park-handles)); the `- park 6 →` form a detail
|
|
526
|
+
* written before it carried is still read.
|
|
527
|
+
*
|
|
528
|
+
* The class is read as written, whatever its word: the card shows the
|
|
529
|
+
* 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.
|
|
531
|
+
*/
|
|
532
|
+
export function harvestProposals(detail) {
|
|
533
|
+
const lines = detail.split('\n');
|
|
534
|
+
const start = lines.findIndex((l) => /^##\s+Harvest\s*$/i.test(l.trim()));
|
|
535
|
+
if (start === -1) {
|
|
536
|
+
return [];
|
|
537
|
+
}
|
|
538
|
+
const end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
|
|
539
|
+
return lines.slice(start + 1, end === -1 ? lines.length : end).flatMap((line) => {
|
|
540
|
+
const m = /^-\s+(?:P|park\s+)(\d+)\s*(?:→|->)\s*([\w-]+)\s*(?::\s*(.*))?$/i.exec(line.trim());
|
|
541
|
+
return m === null ? [] : [{ handle: Number(m[1]), kind: (m[2] ?? '').toLowerCase(), reason: (m[3] ?? '').trim() }];
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* The range a detail file reviews, read off its `# Detail · Range <n> · <stop>`
|
|
546
|
+
* header, or null when the first line is not a range's header (a step's
|
|
547
|
+
* detail, a boundary harvest's, or a file written without one).
|
|
548
|
+
*/
|
|
549
|
+
export function parseDetailRange(detail) {
|
|
550
|
+
const first = detail.trimStart().split('\n', 1)[0] ?? '';
|
|
551
|
+
const m = /^#\s+Detail\s*·\s*Range\s+(\d+)\b/.exec(first);
|
|
552
|
+
return m === null ? null : Number(m[1]);
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The stop card still waiting for its reply, or null when none waits: a STOP
|
|
556
|
+
* stamp, a detail file whose header names the stamp's range, and that range
|
|
557
|
+
* still parsing and reading as landed. The record clears the stamp and the
|
|
558
|
+
* detail file, so the wait ends with it.
|
|
559
|
+
*
|
|
560
|
+
* The digest is left to the record's own latch: a detail file rewritten for
|
|
561
|
+
* a re-render that has not run yet still waits, and a stamp from before the
|
|
562
|
+
* digest carries none. The landed condition is what keeps a re-cut from
|
|
563
|
+
* wedging the build, since a range re-cut to take a new step stops waiting.
|
|
564
|
+
* The stamp is the build's own but the detail file is the session's, so in a
|
|
565
|
+
* session of several builds, a stamp left in this build and another build's
|
|
566
|
+
* card for the same range number read as this build's card waiting.
|
|
567
|
+
*/
|
|
568
|
+
export function waitingCard(root, slug, intent) {
|
|
569
|
+
const stamp = readStop(root, slug);
|
|
570
|
+
if (stamp === null || parseDetailRange(readOr(detailPath(root))) !== stamp.range) {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
const plan = parseBuildRanges(intent);
|
|
574
|
+
const range = plan.ranges.find((r) => r.item === stamp.range);
|
|
575
|
+
if (range === undefined || !rangeLanded(range, parseOrderedSteps(intent))) {
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
return { range: range.item, count: plan.ranges.length + plan.malformed.length };
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* The Harvested part: a lead that sizes the list, then one bullet per open
|
|
582
|
+
* park in list order, its `P6` handle and text from the list and, behind the
|
|
583
|
+
* arrow, the lean the detail file proposes: `lean: tangent, <the reason>`. A
|
|
584
|
+
* park the detail left out reads `no lean proposed`, so a silent omission is
|
|
585
|
+
* one on the card. Null with no park open, which renders the card without the
|
|
586
|
+
* part.
|
|
587
|
+
*
|
|
588
|
+
* A bullet, never a numbered item, because a markdown list renumbers: open
|
|
589
|
+
* parks 6 and 8 would render as 6 and 7, and the number read would not be the
|
|
590
|
+
* one the record matches (D105 (park-handles)).
|
|
591
|
+
*/
|
|
592
|
+
function harvestPart(parks, proposals) {
|
|
593
|
+
const open = parks.filter((p) => p.open);
|
|
594
|
+
if (open.length === 0) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const items = open.map((park) => {
|
|
598
|
+
const proposal = proposals.find((p) => p.handle === park.handle);
|
|
599
|
+
const lean = proposal === undefined ? 'no lean proposed' : proposal.reason.length === 0 ? `lean: ${proposal.kind}` : `lean: ${proposal.kind}, ${proposal.reason}`;
|
|
600
|
+
return `- P${park.handle} · ${park.text} → ${lean}`;
|
|
601
|
+
});
|
|
602
|
+
const lead = `**Harvested**: ${open.length} of ${parks.length} parked idea${parks.length === 1 ? '' : 's'} open`;
|
|
603
|
+
return [lead, '', ...items].join('\n');
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* The stop card's Your Call block: the replies that settle the card in one
|
|
607
|
+
* message, each recording the review and every lean before what it names.
|
|
608
|
+
*
|
|
609
|
+
* While a park is open (`example` set), one line beneath the label says that
|
|
610
|
+
* approving locks in each lean and that changing one means saying so first,
|
|
611
|
+
* with the example naming a handle on the card, since the reply is a decision
|
|
612
|
+
* on every lean at once (D106 (leans-harvested)); the direction row then
|
|
613
|
+
* carries no example of its own. The second row is the move after the stop,
|
|
614
|
+
* read through `stopFollowUp` so the dashboard and `plumbbob build`'s refusal
|
|
615
|
+
* name the same one: the next range's run, the finish on the plan's last
|
|
616
|
+
* range, and nothing when the run would refuse (a flagged range, or a next
|
|
617
|
+
* step no range names), where the note beneath Next Up carries the drift
|
|
618
|
+
* words. With no park open, no line renders, the rows record the review alone,
|
|
619
|
+
* and direction is a change to it.
|
|
620
|
+
*/
|
|
621
|
+
function stopCallBlock(example, intent) {
|
|
622
|
+
const parked = example !== null;
|
|
623
|
+
const what = parked ? 'them' : 'it';
|
|
624
|
+
const moves = [
|
|
625
|
+
callLine('`looks good`', parked ? 'I record the review and every lean as proposed, folding a blocker into the plan; back to the boundary' : 'I record the review; back to the boundary'),
|
|
626
|
+
];
|
|
627
|
+
const follow = stopFollowUp(intent);
|
|
628
|
+
if (follow === 'finish') {
|
|
629
|
+
moves.push(callLine('`/plumbbob:finish`', `I record ${what} the same way, then write the report and close the build`));
|
|
630
|
+
}
|
|
631
|
+
else if (follow !== null) {
|
|
632
|
+
moves.push(callLine('`/plumbbob:run`', `I record ${what} the same way, then run range ${follow.range.item} of ${follow.count} to its stop`));
|
|
633
|
+
}
|
|
634
|
+
moves.push(callLine('anything that reads as direction', `I re-render the card with the ${parked ? 'lean' : 'review'} changed; nothing is recorded until you approve`), callLine('`expand`, or any question', 'I show more of what is there; nothing changes, and `show the stop` brings all of this back'));
|
|
635
|
+
const lockIn = parked ? [`Approving locks in each lean above; to change one, say so first (\`${example}\`).`, ''] : [];
|
|
636
|
+
return ['**Your Call**:', '', ...lockIn, ...moves].join('\n');
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The lock-in line's example, a reply naming a park on this card by its
|
|
640
|
+
* handle: the first open park recast as the class its lean is not, a blocker
|
|
641
|
+
* for a tangent, a pivot, or no lean at all, and a tangent for a blocker, so
|
|
642
|
+
* it always names a change the card could take. Null with no park open, where
|
|
643
|
+
* there is no lean to change.
|
|
644
|
+
*/
|
|
645
|
+
function leanExample(parks, proposals) {
|
|
646
|
+
const first = parks.find((p) => p.open);
|
|
647
|
+
if (first === undefined) {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
const kind = proposals.find((p) => p.handle === first.handle)?.kind;
|
|
651
|
+
return `make P${first.handle} a ${kind === 'blocker' ? 'tangent' : 'blocker'}`;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* A stop's record for the Log: the card as the human settled it, with the
|
|
655
|
+
* full story behind each highlight beneath. The Summary and its highlights,
|
|
656
|
+
* the Readout fence, the Verdict, the Harvested part, the recommendation, then
|
|
657
|
+
* the numbered sections' bodies, blank-line separated and unindented; the
|
|
658
|
+
* caller nests it under the range's dated Log line.
|
|
659
|
+
*
|
|
660
|
+
* Measured, not recalled, through the same review the card rendered. The
|
|
661
|
+
* Harvested part reads the park list as `buildLog` holds it, so the caller
|
|
662
|
+
* passes the list from before its record flips the settled parks, and the
|
|
663
|
+
* part shows each class the human confirmed. Null when the detail file
|
|
664
|
+
* reviews no range the plan declares.
|
|
665
|
+
*/
|
|
666
|
+
export function stopRecord(root, slug, detail, buildLog) {
|
|
667
|
+
const intent = readOr(intentPath(root, slug));
|
|
668
|
+
const item = parseDetailRange(detail);
|
|
669
|
+
const range = parseBuildRanges(intent).ranges.find((r) => r.item === item);
|
|
670
|
+
if (range === undefined) {
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
const review = stopReview(root, slug, intent, range, detail);
|
|
674
|
+
const summary = parseSummary(detail);
|
|
675
|
+
const parts = [];
|
|
676
|
+
if (summary !== null) {
|
|
677
|
+
parts.push(recordSummary(summary));
|
|
678
|
+
}
|
|
679
|
+
if (review.readout.length > 0) {
|
|
680
|
+
parts.push([READOUT_LABEL, '', ...fence('text', review.readout)].join('\n'));
|
|
681
|
+
}
|
|
682
|
+
parts.push(review.verdict);
|
|
683
|
+
const harvest = harvestPart(parkList(buildLog), harvestProposals(detail));
|
|
684
|
+
if (harvest !== null) {
|
|
685
|
+
parts.push(harvest);
|
|
686
|
+
}
|
|
687
|
+
return [...withRecommendation(parts, parseRecommendation(detail)), ...sectionBlocks(parseSections(detail))].join('\n\n');
|
|
688
|
+
}
|
|
289
689
|
/**
|
|
290
690
|
* The step's record for the build-log's Log: the pause as the human approved
|
|
291
691
|
* it, with the full story behind each highlight beneath. The Summary and its
|
|
@@ -462,7 +862,7 @@ function summaryBlock(summary, where) {
|
|
|
462
862
|
if (summary === null) {
|
|
463
863
|
return '';
|
|
464
864
|
}
|
|
465
|
-
const lead = `**Summary**: ${summary.lead} (
|
|
865
|
+
const lead = `**Summary**: ${summary.lead} (${detailsLink(where, null)})`;
|
|
466
866
|
if (summary.highlights.length === 0) {
|
|
467
867
|
return lead;
|
|
468
868
|
}
|
|
@@ -597,11 +997,20 @@ function readCheckSummary(root) {
|
|
|
597
997
|
* `revert` comes last, because a destructive move is named rather than
|
|
598
998
|
* discovered. `looks good` renders only while the measured check is green;
|
|
599
999
|
* offering a move that would refuse teaches a false ceremony.
|
|
1000
|
+
*
|
|
1001
|
+
* `run` is what `/plumbbob:run` typed at this pause names. Inside a range
|
|
1002
|
+
* that lands its steps, with steps still ahead of this one, the run rides
|
|
1003
|
+
* second as the other approval: it lands this step and the rest of the range.
|
|
1004
|
+
* At a bare range's pause, and at a range's last step, it does no more than
|
|
1005
|
+
* `looks good`, so the block keeps its four moves.
|
|
600
1006
|
*/
|
|
601
|
-
function yourCallBlock(step, checkGreen) {
|
|
1007
|
+
function yourCallBlock(step, checkGreen, run) {
|
|
602
1008
|
const moves = [];
|
|
603
1009
|
if (checkGreen) {
|
|
604
1010
|
moves.push(callLine('`looks good`', `I checkpoint Step ${step}; back to the boundary`));
|
|
1011
|
+
if (run.ok && !run.range.bare && run.left[0] === step && run.left.length > 1) {
|
|
1012
|
+
moves.push(callLine('`/plumbbob:run`', `I checkpoint Step ${step}, then run the rest of range ${run.range.item} to its stop`));
|
|
1013
|
+
}
|
|
605
1014
|
}
|
|
606
1015
|
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'));
|
|
607
1016
|
moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
|
|
@@ -621,9 +1030,14 @@ function callLine(move, outcome) {
|
|
|
621
1030
|
* there. Nothing is recorded yet, so `revert` has nothing to wind back to and
|
|
622
1031
|
* vanishes; `looks good` names the step the plan starts at, which is Step 1 at
|
|
623
1032
|
* the plan pause and the first undone step after a mid-build refine.
|
|
1033
|
+
*
|
|
1034
|
+
* When that step sits in a range the run can walk, `looks good` names the run
|
|
1035
|
+
* instead, since the run is what the human types next; a step in no range, or
|
|
1036
|
+
* in a flagged one, keeps the build.
|
|
624
1037
|
*/
|
|
625
|
-
function planCallBlock(first) {
|
|
626
|
-
const
|
|
1038
|
+
function planCallBlock(first, run) {
|
|
1039
|
+
const build = first === undefined ? '' : `; /plumbbob:build starts Step ${first.n}`;
|
|
1040
|
+
const starts = run.ok ? `; /plumbbob:run runs range ${run.range.item} from Step ${run.left[0]}` : build;
|
|
627
1041
|
return [
|
|
628
1042
|
'**Your Call**:',
|
|
629
1043
|
'',
|
|
@@ -658,10 +1072,11 @@ function spikeNextUpLine(inFlight) {
|
|
|
658
1072
|
// count. The line is a reminder; the dashboard holds the whole sequence.
|
|
659
1073
|
const ORDER_CAP = 5;
|
|
660
1074
|
/**
|
|
661
|
-
* The line beneath the forward pointer: the
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
1075
|
+
* The line beneath the forward pointer: the range segment, when the plan
|
|
1076
|
+
* declares build ranges; the build order from the step the pointer names, when
|
|
1077
|
+
* `## Build order` re-sequences the plan away from its numbering; and the park
|
|
1078
|
+
* count, when anything has been parked. '' when none says anything, so a plan
|
|
1079
|
+
* with no ranges, nobody re-sequenced, and an empty park list renders the
|
|
665
1080
|
* pointer alone, as before.
|
|
666
1081
|
*
|
|
667
1082
|
* Indented two spaces, because an indented line continues the part above it
|
|
@@ -670,9 +1085,10 @@ const ORDER_CAP = 5;
|
|
|
670
1085
|
* `Nothing planned`: an unharvested list at the end of the plan is the one
|
|
671
1086
|
* reminder that matters there.
|
|
672
1087
|
*/
|
|
673
|
-
function nextUpNote(intent, buildLog, exclude) {
|
|
1088
|
+
function nextUpNote(intent, buildLog, exclude, range) {
|
|
674
1089
|
const parks = parseParkTotals(buildLog);
|
|
675
1090
|
const segments = [
|
|
1091
|
+
range,
|
|
676
1092
|
buildOrderPhrase(remainingOrder(parseSteps(intent), parseBuildOrder(intent), exclude), ORDER_CAP),
|
|
677
1093
|
parks.total > 0 ? parkedPhrase(parks) : '',
|
|
678
1094
|
].filter((s) => s.length > 0);
|
|
@@ -681,11 +1097,61 @@ function nextUpNote(intent, buildLog, exclude) {
|
|
|
681
1097
|
/**
|
|
682
1098
|
* The forward pointer with its note beneath, as one part: `blocks` keeps a
|
|
683
1099
|
* part's own newlines, so the note rides flush under the line it qualifies.
|
|
1100
|
+
*
|
|
1101
|
+
* `exclude` is the step the turn is about: the step in flight at a pause, the
|
|
1102
|
+
* step just landed at a boundary, null at the plan pause and the plan commit.
|
|
1103
|
+
* While the note suggests a range, the bracket bolds the range's model, since
|
|
1104
|
+
* the run is the range and that is the `/model` the human picks before typing
|
|
1105
|
+
* it.
|
|
684
1106
|
*/
|
|
685
|
-
function nextUpBlock(nextUp,
|
|
686
|
-
const
|
|
1107
|
+
function nextUpBlock(intent, buildLog, nextUp, exclude, where) {
|
|
1108
|
+
const steps = parseOrderedSteps(intent);
|
|
1109
|
+
const range = rangeSegment(intent, steps, nextUp, exclude);
|
|
1110
|
+
const line = nextUpLine(nextUp, steps.length, where, range.model ?? nextUp?.model ?? null);
|
|
1111
|
+
const note = nextUpNote(intent, buildLog, exclude, range.segment);
|
|
687
1112
|
return note.length === 0 ? line : `${line}\n${note}`;
|
|
688
1113
|
}
|
|
1114
|
+
/**
|
|
1115
|
+
* The range segment that leads the note, and the model the pointer's bracket
|
|
1116
|
+
* bolds while it suggests a range: the run `/plumbbob:run` typed now would
|
|
1117
|
+
* walk, `range 3 of 5: /plumbbob:run (steps 6-9)`, with the model of the range
|
|
1118
|
+
* holding the step Next Up names; the re-cut words for a flagged range; the
|
|
1119
|
+
* words for a step no range names; or '' for a plan without ranges.
|
|
1120
|
+
*
|
|
1121
|
+
* The bracket is the grant the hook would mint. At a step pause that is the
|
|
1122
|
+
* range holding the step in flight, its bracket opening on that step, since a
|
|
1123
|
+
* run typed there lands it first; at the pause on a range's last step, where
|
|
1124
|
+
* a run ends on that range's stop, the segment names no run
|
|
1125
|
+
* (`range 3 of 5 lands with step 9`). The model is read with the step the turn
|
|
1126
|
+
* is about landed, so at that last-step pause it is the next range's, the one
|
|
1127
|
+
* the human picks a model for. A flagged range, and a range with no
|
|
1128
|
+
* `- model:` line, leave the step's model in the bracket.
|
|
1129
|
+
*/
|
|
1130
|
+
function rangeSegment(intent, steps, nextUp, exclude) {
|
|
1131
|
+
const landed = steps.map((s) => (s.n === exclude ? { ...s, done: true } : s));
|
|
1132
|
+
const { holding, command } = rangeStanding(parseBuildRanges(intent), landed, nextUp?.n ?? null);
|
|
1133
|
+
const model = holding !== null && command !== null ? holding.model : null;
|
|
1134
|
+
const paused = steps.some((s) => s.n === exclude && !s.done) ? exclude : null;
|
|
1135
|
+
const run = cardRun(intent, paused);
|
|
1136
|
+
if (run.ok) {
|
|
1137
|
+
const name = `range ${run.range.item} of ${run.count}`;
|
|
1138
|
+
const lands = paused !== null && run.left.length === 1;
|
|
1139
|
+
return { segment: lands ? `${name} lands with step ${paused}` : `${name}: /plumbbob:run (${runBracket(run)})`, model };
|
|
1140
|
+
}
|
|
1141
|
+
if (run.kind === 'uncovered') {
|
|
1142
|
+
return { segment: run.words, model };
|
|
1143
|
+
}
|
|
1144
|
+
return { segment: run.kind === 'flagged' ? `range ${run.item} of ${run.count} needs a re-cut (${run.words})` : '', model };
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* The run `/plumbbob:run` typed now would walk, as the card names it: the
|
|
1148
|
+
* range holding `paused`, the step waiting at its pause, or with none the
|
|
1149
|
+
* range holding the next undone step. A spike open is the run's refusal to
|
|
1150
|
+
* make, not the card's, so the card names the run that waits beyond it.
|
|
1151
|
+
*/
|
|
1152
|
+
function cardRun(intent, paused) {
|
|
1153
|
+
return resolveRun({ intent, inFlight: paused, spiking: false, item: null });
|
|
1154
|
+
}
|
|
689
1155
|
/**
|
|
690
1156
|
* The forward pointer: the next undone step with the progress count, its
|
|
691
1157
|
* title, and a closing bracket carrying its advisory `- model:` recommendation
|
|
@@ -693,19 +1159,19 @@ function nextUpBlock(nextUp, total, where, note) {
|
|
|
693
1159
|
* instead.
|
|
694
1160
|
*
|
|
695
1161
|
* The model is the second bold token the line spends, because it is the one
|
|
696
|
-
* the human acts on (a `/model` call) before the next run; the path is
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
1162
|
+
* the human acts on (a `/model` call) before the next run; the path is the
|
|
1163
|
+
* short link every `details:` bracket carries (`detailsLink`). The line alone
|
|
1164
|
+
* is the driver tier's pointer; every other tier rides `nextUpNote` beneath
|
|
1165
|
+
* it, through `nextUpBlock`.
|
|
700
1166
|
*/
|
|
701
|
-
function nextUpLine(nextUp, total, where) {
|
|
1167
|
+
function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
|
|
702
1168
|
if (nextUp === undefined) {
|
|
703
1169
|
return '**Next Up**: Nothing planned - /plumbbob:step or /plumbbob:finish';
|
|
704
1170
|
}
|
|
705
1171
|
const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
|
|
706
|
-
const token = modelToken(
|
|
1172
|
+
const token = modelToken(model);
|
|
707
1173
|
const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
|
|
708
|
-
bracket.push(
|
|
1174
|
+
bracket.push(detailsLink(where, nextUp.line));
|
|
709
1175
|
return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
|
|
710
1176
|
}
|
|
711
1177
|
/**
|