plumbbob 0.13.1 → 0.13.3
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 +80 -58
- package/dist/cli-core.js +21 -6
- package/dist/lib/buildlog.js +77 -0
- package/dist/lib/git.js +53 -0
- package/dist/lib/latch.js +23 -0
- package/dist/lib/orient.js +601 -58
- package/dist/lib/sidecar.js +111 -3
- package/dist/verbs/build.js +38 -3
- package/dist/verbs/checkpoint.js +40 -13
- package/dist/verbs/handoff.js +452 -46
- package/dist/verbs/harvest.js +224 -0
- package/dist/verbs/relay.js +15 -2
- package/dist/verbs/status.js +64 -17
- package/dist/verbs/turn.js +108 -27
- package/package.json +1 -1
- package/skills/build/SKILL.md +18 -6
- package/skills/finish/SKILL.md +18 -4
- package/skills/harvest/SKILL.md +48 -14
- package/skills/plan/SKILL.md +32 -16
- package/skills/refine/SKILL.md +5 -2
- package/skills/run/SKILL.md +256 -0
- package/skills/status/SKILL.md +4 -2
- package/skills/step/SKILL.md +12 -3
- package/skills/verify/SKILL.md +5 -1
- package/templates/intent.md +12 -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, transition } 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, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeStanding, 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.
|
|
@@ -109,7 +117,7 @@ export function handoff(cwd, args = []) {
|
|
|
109
117
|
const plan = [
|
|
110
118
|
SEAM_RULE,
|
|
111
119
|
nextUpBlock(intent, buildLog, first, null, where),
|
|
112
|
-
planCallBlock(first),
|
|
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)));
|
|
@@ -198,7 +206,7 @@ export function handoff(cwd, args = []) {
|
|
|
198
206
|
if (inline) {
|
|
199
207
|
parts.push(fence('diff', patch.split('\n')).join('\n'));
|
|
200
208
|
}
|
|
201
|
-
parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen));
|
|
209
|
+
parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen, cardRun(intent, current)));
|
|
202
210
|
const command = reprintCommand('--full', explicit, named);
|
|
203
211
|
return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
|
|
204
212
|
}
|
|
@@ -284,36 +292,400 @@ export function boundaryEnding(root, slug, step) {
|
|
|
284
292
|
const steps = parseOrderedSteps(intent);
|
|
285
293
|
const nextUp = steps.find((s) => !s.done && s.n !== step);
|
|
286
294
|
const where = relative(root, intentPath(root, slug));
|
|
295
|
+
const landed = landedRange(intent, steps, step);
|
|
287
296
|
return {
|
|
288
297
|
verdict: step === null ? null : landedVerdict(root, slug, intent, step),
|
|
289
|
-
stop:
|
|
298
|
+
stop: landed === null ? null : stopLead(landed, where),
|
|
299
|
+
range: landed === null ? null : { item: landed.range.item, count: landed.count },
|
|
290
300
|
pointer: nextUpBlock(intent, readOr(buildLogPath(root, slug)), nextUp, step, where),
|
|
291
301
|
};
|
|
292
302
|
}
|
|
293
303
|
/**
|
|
294
|
-
* The
|
|
295
|
-
* landed
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
* Null for every other boundary: a landing that leaves the range open, a bare
|
|
300
|
-
* range (its stop was its own pause), a plan without ranges, and the plan
|
|
301
|
-
* commit, where nothing landed.
|
|
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.
|
|
302
308
|
*/
|
|
303
|
-
function
|
|
309
|
+
function landedRange(intent, steps, step) {
|
|
304
310
|
if (step === null) {
|
|
305
311
|
return null;
|
|
306
312
|
}
|
|
307
313
|
const { count, completed } = rangeStanding(parseBuildRanges(intent), steps, step);
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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) {
|
|
311
323
|
return transition({
|
|
312
324
|
label: 'Stop',
|
|
313
|
-
fact: `Range ${
|
|
314
|
-
detail: [
|
|
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),
|
|
315
485
|
});
|
|
316
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
|
+
}
|
|
317
689
|
/**
|
|
318
690
|
* The step's record for the build-log's Log: the pause as the human approved
|
|
319
691
|
* it, with the full story behind each highlight beneath. The Summary and its
|
|
@@ -490,7 +862,7 @@ function summaryBlock(summary, where) {
|
|
|
490
862
|
if (summary === null) {
|
|
491
863
|
return '';
|
|
492
864
|
}
|
|
493
|
-
const lead = `**Summary**: ${summary.lead} (
|
|
865
|
+
const lead = `**Summary**: ${summary.lead} (${detailsLink(where, null)})`;
|
|
494
866
|
if (summary.highlights.length === 0) {
|
|
495
867
|
return lead;
|
|
496
868
|
}
|
|
@@ -625,11 +997,20 @@ function readCheckSummary(root) {
|
|
|
625
997
|
* `revert` comes last, because a destructive move is named rather than
|
|
626
998
|
* discovered. `looks good` renders only while the measured check is green;
|
|
627
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.
|
|
628
1006
|
*/
|
|
629
|
-
function yourCallBlock(step, checkGreen) {
|
|
1007
|
+
function yourCallBlock(step, checkGreen, run) {
|
|
630
1008
|
const moves = [];
|
|
631
1009
|
if (checkGreen) {
|
|
632
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
|
+
}
|
|
633
1014
|
}
|
|
634
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'));
|
|
635
1016
|
moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
|
|
@@ -649,9 +1030,14 @@ function callLine(move, outcome) {
|
|
|
649
1030
|
* there. Nothing is recorded yet, so `revert` has nothing to wind back to and
|
|
650
1031
|
* vanishes; `looks good` names the step the plan starts at, which is Step 1 at
|
|
651
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.
|
|
652
1037
|
*/
|
|
653
|
-
function planCallBlock(first) {
|
|
654
|
-
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;
|
|
655
1041
|
return [
|
|
656
1042
|
'**Your Call**:',
|
|
657
1043
|
'',
|
|
@@ -727,24 +1113,44 @@ function nextUpBlock(intent, buildLog, nextUp, exclude, where) {
|
|
|
727
1113
|
}
|
|
728
1114
|
/**
|
|
729
1115
|
* The range segment that leads the note, and the model the pointer's bracket
|
|
730
|
-
* bolds while it suggests a range:
|
|
731
|
-
*
|
|
732
|
-
*
|
|
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.
|
|
733
1120
|
*
|
|
734
|
-
* The
|
|
735
|
-
*
|
|
736
|
-
*
|
|
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.
|
|
737
1129
|
*/
|
|
738
1130
|
function rangeSegment(intent, steps, nextUp, exclude) {
|
|
739
1131
|
const landed = steps.map((s) => (s.n === exclude ? { ...s, done: true } : s));
|
|
740
|
-
const {
|
|
741
|
-
|
|
742
|
-
|
|
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 };
|
|
743
1140
|
}
|
|
744
|
-
if (
|
|
745
|
-
return { segment:
|
|
1141
|
+
if (run.kind === 'uncovered') {
|
|
1142
|
+
return { segment: run.words, model };
|
|
746
1143
|
}
|
|
747
|
-
return { segment:
|
|
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 });
|
|
748
1154
|
}
|
|
749
1155
|
/**
|
|
750
1156
|
* The forward pointer: the next undone step with the progress count, its
|
|
@@ -753,10 +1159,10 @@ function rangeSegment(intent, steps, nextUp, exclude) {
|
|
|
753
1159
|
* instead.
|
|
754
1160
|
*
|
|
755
1161
|
* The model is the second bold token the line spends, because it is the one
|
|
756
|
-
* the human acts on (a `/model` call) before the next run; the path is
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
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`.
|
|
760
1166
|
*/
|
|
761
1167
|
function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
|
|
762
1168
|
if (nextUp === undefined) {
|
|
@@ -765,7 +1171,7 @@ function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
|
|
|
765
1171
|
const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
|
|
766
1172
|
const token = modelToken(model);
|
|
767
1173
|
const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
|
|
768
|
-
bracket.push(
|
|
1174
|
+
bracket.push(detailsLink(where, nextUp.line));
|
|
769
1175
|
return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
|
|
770
1176
|
}
|
|
771
1177
|
/**
|