plumbbob 0.13.1 → 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 +72 -53
- 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/lib/orient.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// one suggested next move. Pure and best-effort by design: it takes raw file
|
|
4
4
|
// contents (no fs), and a malformed document degrades to fewer fields rather
|
|
5
5
|
// than throwing.
|
|
6
|
-
import { parseStepSeam, scopeDrift } from "./intent.js";
|
|
6
|
+
import { isArtifactPath, parseStepSeam, scopeDrift } from "./intent.js";
|
|
7
7
|
import { expandStepList, formatStepList, hasStep, normalizeStepList, parseStepList, readBuildArgs, } from "./steplist.js";
|
|
8
8
|
/**
|
|
9
9
|
* The lines of a named `## Section`, from its heading to the next `## ` (or
|
|
@@ -106,15 +106,19 @@ export function parseBuildOrder(intent) {
|
|
|
106
106
|
return expandStepList(list, (n) => planned.has(n));
|
|
107
107
|
}
|
|
108
108
|
/**
|
|
109
|
-
* The steps in build order: the
|
|
110
|
-
* then
|
|
109
|
+
* The steps in build order: the landed steps `order` leaves out, then the
|
|
110
|
+
* numbers it lists, in that sequence, then the undone steps it leaves out,
|
|
111
|
+
* each left-out group in document order.
|
|
111
112
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
113
|
+
* `plumbbob order` lists undone steps only, so a landed step the line leaves
|
|
114
|
+
* out landed before the line was written, and it reads as built ahead of the
|
|
115
|
+
* line rather than after it; the undone sequence is the same either way. A
|
|
116
|
+
* listed number takes the first step in document order that carries it, and a
|
|
117
|
+
* repeat of the number is ignored, so a doubled entry cannot double a step; a
|
|
118
|
+
* number no step carries is dropped. An order that names no step, an empty one
|
|
119
|
+
* included, is the identity, which is what a plan with no `## Build order`
|
|
120
|
+
* section reads as. The steps themselves never change, only the sequence, so
|
|
121
|
+
* `n`, `line`, and `done` stay what `parseSteps` read.
|
|
118
122
|
*/
|
|
119
123
|
export function orderSteps(steps, order) {
|
|
120
124
|
const taken = new Set();
|
|
@@ -126,7 +130,11 @@ export function orderSteps(steps, order) {
|
|
|
126
130
|
ordered.push(step);
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
|
-
|
|
133
|
+
const rest = steps.filter((s) => !taken.has(s));
|
|
134
|
+
if (ordered.length === 0) {
|
|
135
|
+
return rest;
|
|
136
|
+
}
|
|
137
|
+
return [...rest.filter((s) => s.done), ...ordered, ...rest.filter((s) => !s.done)];
|
|
130
138
|
}
|
|
131
139
|
/**
|
|
132
140
|
* The plan's steps in build order: `parseSteps` merged with `parseBuildOrder`.
|
|
@@ -159,10 +167,14 @@ export function remainingOrder(steps, order, exclude) {
|
|
|
159
167
|
return byOrder.some((n, i) => n !== byDocument[i]) ? byOrder : [];
|
|
160
168
|
}
|
|
161
169
|
/**
|
|
162
|
-
* The build order as
|
|
163
|
-
* rest folded to a count (`and 2 more`), the way
|
|
164
|
-
* every one-liner; '' for an empty order. With no
|
|
165
|
-
*
|
|
170
|
+
* The build order as the note's labeled field: `order:`, then the next `cap`
|
|
171
|
+
* step numbers in sequence, the rest folded to a count (`and 2 more`), the way
|
|
172
|
+
* a long detail degrades on every one-liner; '' for an empty order. With no
|
|
173
|
+
* cap, the whole sequence.
|
|
174
|
+
*
|
|
175
|
+
* A label, where the phrase once read `build order 7, 8, 5`, because the
|
|
176
|
+
* note's other segments read as labeled fields and a label is how the eye
|
|
177
|
+
* finds one in a line of three (D108 (order-label)).
|
|
166
178
|
*/
|
|
167
179
|
export function buildOrderPhrase(order, cap) {
|
|
168
180
|
if (order.length === 0) {
|
|
@@ -170,7 +182,7 @@ export function buildOrderPhrase(order, cap) {
|
|
|
170
182
|
}
|
|
171
183
|
const shown = cap === undefined ? order : order.slice(0, cap);
|
|
172
184
|
const rest = order.length - shown.length;
|
|
173
|
-
return `
|
|
185
|
+
return `order: ${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
|
|
174
186
|
}
|
|
175
187
|
/**
|
|
176
188
|
* Write `order` as the one numeric line under `## Build order`: in place of
|
|
@@ -351,35 +363,56 @@ export function rangeStanding(plan, ordered, step) {
|
|
|
351
363
|
};
|
|
352
364
|
}
|
|
353
365
|
/** Whether a range has landed: the plan holds a step it names, and every one it holds is done. */
|
|
354
|
-
function rangeLanded(range, ordered) {
|
|
366
|
+
export function rangeLanded(range, ordered) {
|
|
355
367
|
const held = ordered.filter((s) => hasStep(range.steps, s.n));
|
|
356
368
|
return held.length > 0 && held.every((s) => s.done);
|
|
357
369
|
}
|
|
358
370
|
/**
|
|
359
|
-
* The ranges block the dashboard renders, and the
|
|
360
|
-
* holding the next undone step
|
|
371
|
+
* The ranges block the dashboard renders, and the moves it suggests: the run
|
|
372
|
+
* of the range holding the next undone step, with what is left of it, and the
|
|
373
|
+
* stop card of the landed range `waiting` names.
|
|
361
374
|
*
|
|
362
|
-
* Null for a plan without ranges, so the block vanishes. The move is
|
|
363
|
-
* the next step sits in no range, its range is flagged, or the step
|
|
364
|
-
* unplanned, which leaves the dashboard's own step move in charge.
|
|
375
|
+
* Null for a plan without ranges, so the block vanishes. The run's move is
|
|
376
|
+
* null when the next step sits in no range, its range is flagged, or the step
|
|
377
|
+
* is still unplanned, which leaves the dashboard's own step move in charge.
|
|
378
|
+
* The card's move is null unless `waiting` names a range that parses and
|
|
379
|
+
* reads as landed, so a range re-cut to take a new step stops waiting.
|
|
365
380
|
*/
|
|
366
|
-
function rangesView(intent, ordered, next) {
|
|
381
|
+
function rangesView(intent, ordered, next, waiting) {
|
|
367
382
|
const plan = parseBuildRanges(intent);
|
|
368
383
|
const standing = rangeStanding(plan, ordered, next?.n ?? null);
|
|
369
384
|
if (standing.count === 0) {
|
|
370
|
-
return { view: null, move: null };
|
|
385
|
+
return { view: null, move: null, waitingMove: null };
|
|
371
386
|
}
|
|
372
387
|
const own = (item) => standing.drift.filter((d) => d.item === item && d.kind !== 'malformed');
|
|
373
|
-
const rows = plan.ranges.map((range) =>
|
|
374
|
-
range,
|
|
375
|
-
landed:
|
|
376
|
-
|
|
377
|
-
drift: own(range.item).map((d) => d.words),
|
|
378
|
-
}));
|
|
388
|
+
const rows = plan.ranges.map((range) => {
|
|
389
|
+
const landed = rangeLanded(range, ordered);
|
|
390
|
+
return { range, landed, next: range === standing.holding, waiting: landed && range.item === waiting, drift: own(range.item).map((d) => d.words) };
|
|
391
|
+
});
|
|
379
392
|
const view = { count: standing.count, rows, plan: standing.drift.filter((d) => d.item === null || d.kind === 'malformed').map((d) => d.words) };
|
|
380
|
-
const {
|
|
381
|
-
const
|
|
382
|
-
return {
|
|
393
|
+
const run = resolveRun({ intent, inFlight: null, spiking: false, item: null });
|
|
394
|
+
const card = rows.find((r) => r.waiting);
|
|
395
|
+
return {
|
|
396
|
+
view,
|
|
397
|
+
move: !run.ok || next?.planned !== true ? null : runMove(run),
|
|
398
|
+
waitingMove: card === undefined ? null : stopCardMove(card.range.item, standing.count, stopFollowUp(intent)),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The dashboard's pointer while a stop card waits: the card, and in its
|
|
403
|
+
* bracket the reply that records it from any session, the next range's run or
|
|
404
|
+
* the finish on the plan's last range, or the way back to the card when the
|
|
405
|
+
* next run would refuse. `looks good` is never named, since it records only
|
|
406
|
+
* with the card in the conversation.
|
|
407
|
+
*/
|
|
408
|
+
function stopCardMove(item, count, follow) {
|
|
409
|
+
const card = `reply to range ${item} of ${count}'s stop card`;
|
|
410
|
+
if (follow === null) {
|
|
411
|
+
return `${card} (\`show the stop\` brings it back)`;
|
|
412
|
+
}
|
|
413
|
+
return follow === 'finish'
|
|
414
|
+
? `${card} (\`/plumbbob:finish\` records it, then writes the report)`
|
|
415
|
+
: `${card} (\`/plumbbob:run\` records it, then runs range ${follow.range.item} of ${follow.count})`;
|
|
383
416
|
}
|
|
384
417
|
/**
|
|
385
418
|
* What is left of a range as the steps of a command: a landing range's last
|
|
@@ -454,15 +487,20 @@ function sharedDrift(range, ranges, ordered) {
|
|
|
454
487
|
}
|
|
455
488
|
/**
|
|
456
489
|
* A range the build order cuts short: an undone step it does not name, placed
|
|
457
|
-
* between
|
|
490
|
+
* between its first step and its last undone one, where a run of the range
|
|
491
|
+
* halts.
|
|
458
492
|
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
493
|
+
* The span opens at the range's first step, landed or not, so a range a run
|
|
494
|
+
* halted inside stays flagged until the step that halted it lands. It ends at
|
|
495
|
+
* the range's last undone step, since a step placed after every one of them
|
|
496
|
+
* cuts nothing short, and a range with none left reports nothing. An outside
|
|
497
|
+
* step ahead of the range's first step builds first and cuts nothing, and the
|
|
498
|
+
* card names its range before this one.
|
|
461
499
|
*/
|
|
462
500
|
function insideDrift(range, ordered) {
|
|
463
501
|
const first = ordered.findIndex((s) => hasStep(range.steps, s.n));
|
|
464
|
-
const last = ordered.findLastIndex((s) => hasStep(range.steps, s.n));
|
|
465
|
-
const inside =
|
|
502
|
+
const last = ordered.findLastIndex((s) => !s.done && hasStep(range.steps, s.n));
|
|
503
|
+
const inside = last === -1 ? [] : ordered.slice(first + 1, last).filter((s) => !s.done && !hasStep(range.steps, s.n));
|
|
466
504
|
if (inside.length === 0) {
|
|
467
505
|
return [];
|
|
468
506
|
}
|
|
@@ -477,6 +515,243 @@ function stepPhrase(items) {
|
|
|
477
515
|
const one = items.length === 1 && /^\d+$/.test(items[0] ?? '');
|
|
478
516
|
return { words: `${one ? 'step' : 'steps'} ${items.join(', ')}`, be: one ? 'is' : 'are' };
|
|
479
517
|
}
|
|
518
|
+
// --- The run: what `/plumbbob:run` names. The turn hook reads it to mint the
|
|
519
|
+
// run's grant, `status --run` to print the run header, and the card to name the
|
|
520
|
+
// run beneath Next Up, so all three read one answer from one plan. ---
|
|
521
|
+
/**
|
|
522
|
+
* The range a run's arguments name, read from the one token after the verb:
|
|
523
|
+
* a range number names that range, and anything else, nothing included, names
|
|
524
|
+
* the range holding the next step.
|
|
525
|
+
*
|
|
526
|
+
* `--auto` beside the verb adds nothing, so it is passed over. Numbers joined
|
|
527
|
+
* by a dash or a comma are a span or a list of ranges (`2-4`, `2, 3`), a form
|
|
528
|
+
* no run defines, and read as null, so nothing is minted or resolved for them.
|
|
529
|
+
* Trailing sentence punctuation is the sentence's, and so is a comma after the
|
|
530
|
+
* last number (`/plumbbob:run 3, then review`), as it is after a build's.
|
|
531
|
+
*/
|
|
532
|
+
export function readRunArgs(text) {
|
|
533
|
+
const words = [];
|
|
534
|
+
for (const token of text.split(/\s+/)) {
|
|
535
|
+
const word = token.replace(/[.;:!?]+$/, '');
|
|
536
|
+
if (word === '' || word === '--auto') {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (!/^[\d,-]+$/.test(word)) {
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
words.push(word);
|
|
543
|
+
}
|
|
544
|
+
const run = words.join(' ').replace(/,+$/, '');
|
|
545
|
+
if (/[,-]/.test(run)) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
return { item: run === '' ? null : Number(run.split(' ')[0]) };
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* The range a run names and what is left of it, or the refusal that says why
|
|
552
|
+
* it names nothing it can walk.
|
|
553
|
+
*
|
|
554
|
+
* A step in flight decides the run: with no number typed, the run names the
|
|
555
|
+
* range holding that step, or with none in flight the range holding the next
|
|
556
|
+
* undone step in build order; a number names that range, and refuses when the
|
|
557
|
+
* range does not hold the step in flight. What is left counts the step in
|
|
558
|
+
* flight as undone and leads with it, since a run that started elsewhere would
|
|
559
|
+
* rewrite the step markers and sweep its half-built diff into another step's
|
|
560
|
+
* commit. A spike open refuses every run, and nothing left refuses before the
|
|
561
|
+
* ranges are read. The ranges are read through `rangeStanding`, so the run
|
|
562
|
+
* and the card name the same range and the same drift.
|
|
563
|
+
*/
|
|
564
|
+
export function resolveRun(input) {
|
|
565
|
+
if (input.spiking) {
|
|
566
|
+
return { ok: false, kind: 'spike' };
|
|
567
|
+
}
|
|
568
|
+
const ordered = parseOrderedSteps(input.intent);
|
|
569
|
+
const anchor = input.inFlight ?? ordered.find((s) => !s.done)?.n ?? null;
|
|
570
|
+
if (anchor === null) {
|
|
571
|
+
return { ok: false, kind: 'finished' };
|
|
572
|
+
}
|
|
573
|
+
const plan = parseBuildRanges(input.intent);
|
|
574
|
+
const standing = rangeStanding(plan, ordered, anchor);
|
|
575
|
+
const { count } = standing;
|
|
576
|
+
if (count === 0) {
|
|
577
|
+
return { ok: false, kind: 'no-ranges', step: anchor };
|
|
578
|
+
}
|
|
579
|
+
if (input.item === null) {
|
|
580
|
+
const { holding } = standing;
|
|
581
|
+
// With no range that parses, no step sits outside one, so the words are
|
|
582
|
+
// the first item's that does not.
|
|
583
|
+
const words = standing.flag ?? standing.drift.find((d) => d.kind === 'malformed')?.words ?? noRangeWords([anchor]);
|
|
584
|
+
return holding === null
|
|
585
|
+
? { ok: false, kind: 'uncovered', step: anchor, words }
|
|
586
|
+
: walkRun(holding, standing, ordered, input.inFlight);
|
|
587
|
+
}
|
|
588
|
+
const item = input.item;
|
|
589
|
+
const range = plan.ranges.find((r) => r.item === item);
|
|
590
|
+
if (range === undefined) {
|
|
591
|
+
const malformed = standing.drift.find((d) => d.kind === 'malformed' && d.item === item);
|
|
592
|
+
return malformed === undefined
|
|
593
|
+
? { ok: false, kind: 'missing', item, instead: resolveRun({ ...input, item: null }) }
|
|
594
|
+
: { ok: false, kind: 'flagged', item, count, words: malformed.words };
|
|
595
|
+
}
|
|
596
|
+
if (rangeLanded(range, ordered)) {
|
|
597
|
+
return { ok: false, kind: 'landed', item, count, instead: resolveRun({ ...input, item: null }) };
|
|
598
|
+
}
|
|
599
|
+
if (input.inFlight !== null && !hasStep(range.steps, input.inFlight)) {
|
|
600
|
+
return { ok: false, kind: 'in-flight', step: input.inFlight, holding: standing.holding?.item ?? null, count };
|
|
601
|
+
}
|
|
602
|
+
return walkRun(range, standing, ordered, input.inFlight);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* A range as a run walks it: what is left of it, led by the step in flight,
|
|
606
|
+
* and the grant the hook mints; or the flagged refusal when the range's own
|
|
607
|
+
* drift names it.
|
|
608
|
+
*/
|
|
609
|
+
function walkRun(range, standing, ordered, inFlight) {
|
|
610
|
+
const flag = standing.drift.find((d) => d.item === range.item && d.kind !== 'malformed');
|
|
611
|
+
if (flag !== undefined) {
|
|
612
|
+
return { ok: false, kind: 'flagged', item: range.item, count: standing.count, words: flag.words };
|
|
613
|
+
}
|
|
614
|
+
const undone = ordered.filter((s) => hasStep(range.steps, s.n) && (!s.done || s.n === inFlight)).map((s) => s.n);
|
|
615
|
+
const left = inFlight !== null && undone.includes(inFlight) ? [inFlight, ...undone.filter((n) => n !== inFlight)] : undone;
|
|
616
|
+
return {
|
|
617
|
+
ok: true,
|
|
618
|
+
range,
|
|
619
|
+
count: standing.count,
|
|
620
|
+
left,
|
|
621
|
+
steps: formatStepList(left),
|
|
622
|
+
grant: range.bare ? null : `steps ${normalizeStepList(left.map((n) => ({ from: n, to: n })))}`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The run a skill invocation's raw arguments name, read as the turn hook reads
|
|
627
|
+
* them: `readRunArgs`, then `resolveRun`. A span or a list of ranges names no
|
|
628
|
+
* run, and refuses with the run a bare `/plumbbob:run` names in its place.
|
|
629
|
+
*/
|
|
630
|
+
export function resolveRunArgs(text, build) {
|
|
631
|
+
const args = readRunArgs(text);
|
|
632
|
+
if (args === null) {
|
|
633
|
+
return { ok: false, kind: 'span', instead: resolveRun({ ...build, item: null }) };
|
|
634
|
+
}
|
|
635
|
+
return resolveRun({ ...build, item: args.item });
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* What is left of a run, as its bracket and its header write it: `steps 6-9`,
|
|
639
|
+
* `steps 20,5-9`, or `step 9` for one step.
|
|
640
|
+
*/
|
|
641
|
+
export function runSteps(run) {
|
|
642
|
+
return `${run.left.length === 1 ? 'step' : 'steps'} ${run.steps}`;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* What the card's bracket says the run lands: what is left of it, and for a
|
|
646
|
+
* bare range that it builds the step to its pause (`step 10 to its pause`).
|
|
647
|
+
*/
|
|
648
|
+
export function runBracket(run) {
|
|
649
|
+
return run.range.bare ? `${runSteps(run)} to its pause` : runSteps(run);
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The move after a stop, the one the stop card's second row, the dashboard's
|
|
653
|
+
* waiting pointer, and `plumbbob build`'s refusal all name: the run a bare
|
|
654
|
+
* `/plumbbob:run` names, `'finish'` once no step is left, or null when the
|
|
655
|
+
* next run would refuse, where a reply records the card and nothing more.
|
|
656
|
+
*/
|
|
657
|
+
export function stopFollowUp(intent) {
|
|
658
|
+
const run = resolveRun({ intent, inFlight: null, spiking: false, item: null });
|
|
659
|
+
if (run.ok) {
|
|
660
|
+
return run;
|
|
661
|
+
}
|
|
662
|
+
return run.kind === 'finished' ? 'finish' : null;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* The pointer naming a run: the range and its count, the run's command, and
|
|
666
|
+
* the bracket, with a note beside the steps when one is given.
|
|
667
|
+
*/
|
|
668
|
+
export function runMove(run, note) {
|
|
669
|
+
return `run range ${run.range.item} of ${run.count} — \`/plumbbob:run\` (${runBracket(run)}${note === undefined ? '' : `; ${note}`})`;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The dashboard's pointer for a run that names nothing it can walk, in the
|
|
673
|
+
* dashboard's own register: the refusal and the move that unblocks it. Null
|
|
674
|
+
* for nothing left, whose move is the dashboard's own ending, which a run adds
|
|
675
|
+
* nothing to.
|
|
676
|
+
*/
|
|
677
|
+
export function runRefusalMove(refusal) {
|
|
678
|
+
const recut = 're-cut the ranges — `/plumbbob:refine ranges`';
|
|
679
|
+
switch (refusal.kind) {
|
|
680
|
+
case 'spike':
|
|
681
|
+
return 'close the spike — `plumbbob spike done`';
|
|
682
|
+
case 'finished':
|
|
683
|
+
return null;
|
|
684
|
+
case 'no-ranges':
|
|
685
|
+
return `cut the ranges — \`/plumbbob:refine ranges\` (the plan declares none; \`/plumbbob:build\` builds step ${refusal.step} alone)`;
|
|
686
|
+
case 'uncovered':
|
|
687
|
+
return `${recut} (${refusal.words})`;
|
|
688
|
+
case 'flagged':
|
|
689
|
+
return `${recut} (range ${refusal.item} of ${refusal.count} needs a re-cut: ${refusal.words})`;
|
|
690
|
+
case 'landed':
|
|
691
|
+
return insteadMove(refusal.instead, `range ${refusal.item} of ${refusal.count} has landed`);
|
|
692
|
+
case 'missing':
|
|
693
|
+
return insteadMove(refusal.instead, `the plan has no range ${refusal.item}`);
|
|
694
|
+
case 'span':
|
|
695
|
+
return insteadMove(refusal.instead, 'a run names one range');
|
|
696
|
+
case 'in-flight': {
|
|
697
|
+
const holds = refusal.holding === null ? 'no range names it' : `a run names range ${refusal.holding} of ${refusal.count}, which holds it`;
|
|
698
|
+
return `finish step ${refusal.step} — \`/plumbbob:verify\` (or \`/plumbbob:abandon\` it; ${holds})`;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The pointer at the run a bare `/plumbbob:run` names, carrying why the typed
|
|
704
|
+
* one was refused, or that run's own refusal when it names nothing either.
|
|
705
|
+
*/
|
|
706
|
+
function insteadMove(instead, note) {
|
|
707
|
+
return instead.ok ? runMove(instead, note) : runRefusalMove(instead);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The three-line header `status --run` prints above the dashboard: the range,
|
|
711
|
+
* what is left of it, and where the run ends; its stop; and the grant the
|
|
712
|
+
* hook minted, held against the one the run would mint.
|
|
713
|
+
*
|
|
714
|
+
* `grant` is the GRANT file's content in its normal form, or null when none
|
|
715
|
+
* stands. The grant line is the one the run skill trusts over the two above
|
|
716
|
+
* it, since the hook read the plan the human typed against, so it says when
|
|
717
|
+
* the two differ rather than choosing between them.
|
|
718
|
+
*/
|
|
719
|
+
export function formatRunHeader(run, grant) {
|
|
720
|
+
const last = run.left.at(-1) ?? run.range.steps[0]?.from;
|
|
721
|
+
const ends = run.range.bare ? `ends at step ${last}'s pause` : `ends at step ${last}'s boundary`;
|
|
722
|
+
return [
|
|
723
|
+
`run range ${run.range.item} of ${run.count} · ${runSteps(run)} · ${ends}`,
|
|
724
|
+
` stop: ${run.range.stop}`,
|
|
725
|
+
` grant: ${grantWords(run, grant)}`,
|
|
726
|
+
].join('\n');
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* The grant line's value: the grant as the hook minted it, `none` with its
|
|
730
|
+
* reason, or the grant marked unlike the run.
|
|
731
|
+
*/
|
|
732
|
+
function grantWords(run, grant) {
|
|
733
|
+
if (grant === null) {
|
|
734
|
+
return run.grant === null ? 'none, as a bare range builds to its pause' : 'none';
|
|
735
|
+
}
|
|
736
|
+
return grant === run.grant ? grant : `${grant}, unlike the run`;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The dashboard `status --run` prints for a run: beneath the run header, with
|
|
740
|
+
* the run's first step marked `← requested` and the pointer naming the run;
|
|
741
|
+
* or, for a run that names nothing it can walk, the dashboard as it renders
|
|
742
|
+
* without the flag, its pointer carrying the refusal. Nothing left keeps the
|
|
743
|
+
* dashboard's own pointer, which names the stop card while one waits on the
|
|
744
|
+
* plan's last range, its bracket the finish that records it.
|
|
745
|
+
*/
|
|
746
|
+
export function formatRunDashboard(input, run, grant) {
|
|
747
|
+
if (!run.ok) {
|
|
748
|
+
const plain = orient({ ...input, requested: null });
|
|
749
|
+
return formatOrientation({ ...plain, next: runRefusalMove(run) ?? plain.next });
|
|
750
|
+
}
|
|
751
|
+
const first = run.left[0] ?? null;
|
|
752
|
+
const repointed = orient({ ...input, requested: first === null ? null : [{ from: first, to: first }] });
|
|
753
|
+
return `${formatRunHeader(run, grant)}\n\n${formatOrientation({ ...repointed, next: runMove(run) })}`;
|
|
754
|
+
}
|
|
480
755
|
/**
|
|
481
756
|
* Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section.
|
|
482
757
|
*
|
|
@@ -712,9 +987,13 @@ export function orient(input) {
|
|
|
712
987
|
const seamParse = target === undefined ? null : parseStepSeam(input.intent, target.n);
|
|
713
988
|
const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
|
|
714
989
|
// At the boundary with nothing asked for, the move names the range holding
|
|
715
|
-
// the next step; a spike, a step in flight, or a request keeps its own.
|
|
716
|
-
|
|
717
|
-
|
|
990
|
+
// the next step; a spike, a step in flight, or a request keeps its own. A
|
|
991
|
+
// stop card waiting for its reply outranks the run and a request, since a
|
|
992
|
+
// step entered now would write over the range's review; a spike and a step
|
|
993
|
+
// in flight still keep theirs.
|
|
994
|
+
const ranges = rangesView(input.intent, ordered, nextUndone, input.waiting);
|
|
995
|
+
const idle = !input.spiking && input.inFlight === null;
|
|
996
|
+
const atBoundary = idle && requested === null;
|
|
718
997
|
return {
|
|
719
998
|
title: parseTitle(input.intent),
|
|
720
999
|
phase,
|
|
@@ -723,7 +1002,9 @@ export function orient(input) {
|
|
|
723
1002
|
parked: parks.open,
|
|
724
1003
|
parkedTotal: parks.total,
|
|
725
1004
|
openQuestions: parseOpenQuestions(input.intent),
|
|
726
|
-
next: (
|
|
1005
|
+
next: (idle ? ranges.waitingMove : null) ??
|
|
1006
|
+
(atBoundary ? ranges.move : null) ??
|
|
1007
|
+
nextMove(input.spiking, ordered, input.inFlight, parks.open, requested),
|
|
727
1008
|
nextUndone: nextUndone?.n ?? null,
|
|
728
1009
|
buildOrder: remainingOrder(steps, order, null),
|
|
729
1010
|
ranges: ranges.view,
|
|
@@ -773,19 +1054,24 @@ function classifyRecapRow(name, value) {
|
|
|
773
1054
|
* `── recap · step N of M ──` header, the (subset of) five measuring rows
|
|
774
1055
|
* present, and the free-text diff row. Null when no header is found.
|
|
775
1056
|
*
|
|
1057
|
+
* A range's detail opens its rows under `── recap · range N of M ──` instead,
|
|
1058
|
+
* and only the `range` scope reads that header, so a step pause never takes a
|
|
1059
|
+
* stop's judgment rows for its own, and the stop card never takes a step's.
|
|
1060
|
+
*
|
|
776
1061
|
* A row absent from the block is simply missing from `rows`: a vanished row
|
|
777
1062
|
* the fold treats exactly like one that never applied, never like a failure.
|
|
778
1063
|
* An indented `- ` or `→ ` line attaches to the row above it, which is how a
|
|
779
1064
|
* row carrying two or more items, or a pointer at its evidence, survives the
|
|
780
1065
|
* round trip through the file.
|
|
781
1066
|
*/
|
|
782
|
-
export function parseRecap(detail) {
|
|
1067
|
+
export function parseRecap(detail, scope = 'step') {
|
|
783
1068
|
const lines = detail.split('\n');
|
|
784
|
-
const
|
|
1069
|
+
const opener = new RegExp(`^──\\s*recap\\s*·\\s*${scope}\\s+\\d+\\s+of\\s+\\d+\\s*──$`);
|
|
1070
|
+
const headerIdx = lines.findIndex((l) => opener.test(l.trim()));
|
|
785
1071
|
if (headerIdx === -1) {
|
|
786
1072
|
return null;
|
|
787
1073
|
}
|
|
788
|
-
const header =
|
|
1074
|
+
const header = new RegExp(`${scope}\\s+(\\d+)\\s+of\\s+(\\d+)`).exec(lines[headerIdx] ?? '');
|
|
789
1075
|
if (header === null) {
|
|
790
1076
|
return null;
|
|
791
1077
|
}
|
|
@@ -824,7 +1110,7 @@ export function parseRecap(detail) {
|
|
|
824
1110
|
}
|
|
825
1111
|
}
|
|
826
1112
|
}
|
|
827
|
-
return {
|
|
1113
|
+
return { n: Number(header[1]), total: Number(header[2]), rows, diff };
|
|
828
1114
|
}
|
|
829
1115
|
const PLUMB = { glyph: '●', state: 'Plumb' };
|
|
830
1116
|
const A_HAIR_OFF = { glyph: '◐', state: 'A hair off' };
|
|
@@ -842,8 +1128,13 @@ const NOT_STANDING = { glyph: '✗', state: 'Not standing' };
|
|
|
842
1128
|
* carries the stray; at the boundary the tree is clean, the row has vanished,
|
|
843
1129
|
* and the stat the checkpoint bumped carries it instead. Both name it
|
|
844
1130
|
* `seam strayed`, so the rung does not change between the two.
|
|
1131
|
+
*
|
|
1132
|
+
* A stop card folds a range the same way, its stats summed across the steps,
|
|
1133
|
+
* and only the revert's words change with the scope (`1 revert in the
|
|
1134
|
+
* range`), since the other advisories already read across whatever they
|
|
1135
|
+
* count.
|
|
845
1136
|
*/
|
|
846
|
-
export function foldVerdict(rows, stats) {
|
|
1137
|
+
export function foldVerdict(rows, stats, scope = 'step') {
|
|
847
1138
|
for (const name of RECAP_ROW_NAMES) {
|
|
848
1139
|
if (rows[name]?.verdict === 'drift') {
|
|
849
1140
|
return { ladder: NOT_STANDING, worst: `${name} drifted` };
|
|
@@ -871,7 +1162,8 @@ export function foldVerdict(rows, stats) {
|
|
|
871
1162
|
return { ladder: A_HAIR_OFF, worst: `${stats.redChecks} red run${stats.redChecks === 1 ? '' : 's'} before green` };
|
|
872
1163
|
}
|
|
873
1164
|
if (stats.reverts > 0) {
|
|
874
|
-
|
|
1165
|
+
const where = scope === 'range' ? 'in the range' : 'on this step';
|
|
1166
|
+
return { ladder: A_HAIR_OFF, worst: `${stats.reverts} revert${stats.reverts === 1 ? '' : 's'} ${where}` };
|
|
875
1167
|
}
|
|
876
1168
|
if (stats.outOfBand > 0) {
|
|
877
1169
|
return { ladder: A_HAIR_OFF, worst: `${stats.outOfBand} commit${stats.outOfBand === 1 ? '' : 's'} outside the ledger` };
|
|
@@ -898,15 +1190,20 @@ const CHECK_SUMMARY = '.check/summary.json';
|
|
|
898
1190
|
* Red expands where green collapses: one failing slot is named in the row and
|
|
899
1191
|
* its raw output is the `→` pointer, and two or more collapse to a count with
|
|
900
1192
|
* one `- ` line per slot.
|
|
1193
|
+
*
|
|
1194
|
+
* A stop card passes the range's `landings`, and a green row counts them
|
|
1195
|
+
* behind the gate's size: every landing passed the gate, since the checkpoint
|
|
1196
|
+
* refuses on red.
|
|
901
1197
|
*/
|
|
902
|
-
export function summaryCheckRow(summary) {
|
|
1198
|
+
export function summaryCheckRow(summary, landings = 0) {
|
|
903
1199
|
const ran = summary.checks.filter((c) => c.skipped !== true);
|
|
904
1200
|
const skipped = summary.checks.filter((c) => c.skipped === true).map((c) => c.name);
|
|
905
1201
|
const named = skipped.length === 0 ? '' : ` · without ${skipped.join(', ')}`;
|
|
906
1202
|
const counted = skipped.length === 0 ? '' : ` · without ${skipped.length} others`;
|
|
907
1203
|
const fit = (long, short) => (long.length <= RECAP_VALUE_BUDGET ? long : short);
|
|
908
1204
|
if (summary.ok) {
|
|
909
|
-
const
|
|
1205
|
+
const landed = landings === 0 ? '' : `, ${landings} landing${landings === 1 ? '' : 's'}`;
|
|
1206
|
+
const size = `${ran.length} of ${summary.checks.length} checks${landed}`;
|
|
910
1207
|
return { verdict: 'true', word: 'green', evidence: fit(`green: ${size}${named}`, `green: ${size}${counted}`) };
|
|
911
1208
|
}
|
|
912
1209
|
const failing = ran.filter((c) => !c.ok);
|
|
@@ -1023,8 +1320,16 @@ function elapsedSpent(input) {
|
|
|
1023
1320
|
return null;
|
|
1024
1321
|
}
|
|
1025
1322
|
const landed = input.landedAt === undefined ? Number.NaN : Date.parse(input.landedAt);
|
|
1026
|
-
|
|
1027
|
-
|
|
1323
|
+
return durationWords((Number.isFinite(landed) ? landed : input.now) - started);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* A span of time as the `spent` row says it: seconds under a minute, minutes
|
|
1327
|
+
* under two hours, then hours and minutes. Null for a span that runs
|
|
1328
|
+
* backwards or does not parse, since a negative age is worse than a missing
|
|
1329
|
+
* one.
|
|
1330
|
+
*/
|
|
1331
|
+
function durationWords(ms) {
|
|
1332
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
1028
1333
|
return null;
|
|
1029
1334
|
}
|
|
1030
1335
|
if (ms < 60_000) {
|
|
@@ -1065,12 +1370,20 @@ function collapseRow(name, row, constraints) {
|
|
|
1065
1370
|
* column 14. A row's `- ` items and its `→ ` pointer follow it on indented
|
|
1066
1371
|
* continuation lines.
|
|
1067
1372
|
*
|
|
1068
|
-
* The step's identity is not in here: the
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1373
|
+
* The step's identity is not in here: the Now line above the card carries it,
|
|
1374
|
+
* so it renders once. A stop card's range has no Now line, and its `steps`
|
|
1375
|
+
* row opens the fence in that place. Empty when no row survived, so the caller
|
|
1376
|
+
* can drop the label and the fence together rather than emit an empty
|
|
1377
|
+
* instrument.
|
|
1071
1378
|
*/
|
|
1072
1379
|
export function recapLines(rows, extras) {
|
|
1073
1380
|
const body = [];
|
|
1381
|
+
if (extras.steps !== undefined) {
|
|
1382
|
+
body.push(`${'steps'.padEnd(13)}${extras.steps.evidence}`);
|
|
1383
|
+
for (const item of extras.steps.items) {
|
|
1384
|
+
body.push(`${RECAP_INDENT}- ${item}`);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1074
1387
|
for (const name of RECAP_ROW_NAMES) {
|
|
1075
1388
|
const row = rows[name];
|
|
1076
1389
|
if (row === undefined) {
|
|
@@ -1093,6 +1406,230 @@ export function recapLines(rows, extras) {
|
|
|
1093
1406
|
}
|
|
1094
1407
|
return body;
|
|
1095
1408
|
}
|
|
1409
|
+
/** The short form a sha takes on the `steps` row, a handle for `git show`. */
|
|
1410
|
+
const SHORT_SHA = 7;
|
|
1411
|
+
/**
|
|
1412
|
+
* Measure a landed range for the stop card's readout: its `steps` row, the
|
|
1413
|
+
* `check` and `seam` rows the CLI owns, the `diff` and `spent` rows, and the
|
|
1414
|
+
* stats the Verdict folds.
|
|
1415
|
+
*
|
|
1416
|
+
* Each step is held to its own commit and its own seam, never the range's
|
|
1417
|
+
* union, so a step that wandered into a neighbor's seam still wandered. A
|
|
1418
|
+
* step the ledger holds no commit for reads `no checkpoint` on the `steps` row
|
|
1419
|
+
* and adds nothing else. A step landed by hand, with a checkpoint but no
|
|
1420
|
+
* `build` entry, adds its commit to every row and no time to `spent`; its
|
|
1421
|
+
* counters still count, so a red run its checkpoint's gate refused reads the
|
|
1422
|
+
* same on the `spent` row and in the Verdict.
|
|
1423
|
+
*/
|
|
1424
|
+
export function measureRange(input) {
|
|
1425
|
+
const held = parseOrderedSteps(input.intent).filter((s) => hasStep(input.range.steps, s.n));
|
|
1426
|
+
const shas = stepShas(input.checkpoints);
|
|
1427
|
+
const landed = held.flatMap((s) => {
|
|
1428
|
+
const sha = shas.get(s.n);
|
|
1429
|
+
return sha === undefined ? [] : [{ n: s.n, files: input.commits[sha] ?? [] }];
|
|
1430
|
+
});
|
|
1431
|
+
const rows = {};
|
|
1432
|
+
if (input.summary !== null) {
|
|
1433
|
+
rows.check = summaryCheckRow(input.summary, landed.length);
|
|
1434
|
+
}
|
|
1435
|
+
const seam = rangeSeamRow(input.intent, landed, held.length);
|
|
1436
|
+
if (seam !== null) {
|
|
1437
|
+
rows.seam = seam;
|
|
1438
|
+
}
|
|
1439
|
+
const stats = held.map((s) => input.stats[String(s.n)] ?? {});
|
|
1440
|
+
const sum = (key) => stats.reduce((total, s) => total + (s[key] ?? 0), 0);
|
|
1441
|
+
const recorded = ledgerShas(input.checkpoints);
|
|
1442
|
+
return {
|
|
1443
|
+
steps: stepsRow(held, shas),
|
|
1444
|
+
rows,
|
|
1445
|
+
diff: rangeDiffValue(landed.flatMap((l) => l.files)),
|
|
1446
|
+
spent: rangeSpentValue(stats),
|
|
1447
|
+
stats: {
|
|
1448
|
+
driftWarnings: sum('driftWarnings'),
|
|
1449
|
+
redChecks: sum('redChecks'),
|
|
1450
|
+
reverts: sum('reverts'),
|
|
1451
|
+
outOfBand: input.between.filter((sha) => !recorded.has(sha)).length,
|
|
1452
|
+
},
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* The ledger span a range landed across, for the out-of-band count: from the
|
|
1457
|
+
* line before the range's first landing to the range's last. Null when no
|
|
1458
|
+
* step of the range is on the ledger, or nothing precedes its first landing.
|
|
1459
|
+
*
|
|
1460
|
+
* The caller asks git for the first-parent commits in `from..to`, plumbbob's
|
|
1461
|
+
* plan commits left out, and hands them to `measureRange` as `between`; the
|
|
1462
|
+
* ledger's own commits drop out there, so what is left is the range's
|
|
1463
|
+
* commits outside the ledger.
|
|
1464
|
+
*/
|
|
1465
|
+
export function rangeSpan(checkpoints, range) {
|
|
1466
|
+
const lines = ledgerLines(checkpoints);
|
|
1467
|
+
const inRange = (line) => line.step !== null && hasStep(range.steps, line.step);
|
|
1468
|
+
const first = lines.findIndex(inRange);
|
|
1469
|
+
const last = lines.findLastIndex(inRange);
|
|
1470
|
+
const from = lines[first - 1]?.sha;
|
|
1471
|
+
const to = lines[last]?.sha;
|
|
1472
|
+
return first < 1 || from === undefined || to === undefined ? null : { from, to };
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* The ledger's lines of every kind, in the order they were written.
|
|
1476
|
+
*/
|
|
1477
|
+
function ledgerLines(checkpoints) {
|
|
1478
|
+
return checkpoints.split('\n').flatMap((line) => {
|
|
1479
|
+
const m = /^(?:baseline|plan|step\s+(\d+))\s+(\S+)/.exec(line.trim());
|
|
1480
|
+
return m === null ? [] : [{ step: m[1] === undefined ? null : Number(m[1]), sha: m[2] ?? '' }];
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Each step's checkpoint sha, the last line for a step landed twice (a revert,
|
|
1485
|
+
* then a landing again), since that is the commit on the branch now.
|
|
1486
|
+
*/
|
|
1487
|
+
export function stepShas(checkpoints) {
|
|
1488
|
+
const shas = new Map();
|
|
1489
|
+
for (const line of ledgerLines(checkpoints)) {
|
|
1490
|
+
if (line.step !== null) {
|
|
1491
|
+
shas.set(line.step, line.sha);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
return shas;
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Every sha the ledger records, of any kind: the commits plumbbob made, so
|
|
1498
|
+
* none of them counts as out-of-band.
|
|
1499
|
+
*/
|
|
1500
|
+
function ledgerShas(checkpoints) {
|
|
1501
|
+
return new Set(ledgerLines(checkpoints).map((line) => line.sha));
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* The `steps` row: how many of the range's steps landed, then each step and
|
|
1505
|
+
* its checkpoint sha in build order, packed onto continuation lines by width
|
|
1506
|
+
* rather than one per step, since each is a handle and not an item to judge.
|
|
1507
|
+
*/
|
|
1508
|
+
function stepsRow(held, shas) {
|
|
1509
|
+
const done = held.filter((s) => s.done).length;
|
|
1510
|
+
const handles = held.map((s) => {
|
|
1511
|
+
const sha = shas.get(s.n);
|
|
1512
|
+
return `${s.n} ${sha === undefined ? 'no checkpoint' : sha.slice(0, SHORT_SHA)}`;
|
|
1513
|
+
});
|
|
1514
|
+
return { evidence: `${done} of ${held.length} landed`, items: packed(handles, RECAP_VALUE_BUDGET - 2) };
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Pack items onto lines joined by ` · `, a new line when the next would
|
|
1518
|
+
* overrun `width`.
|
|
1519
|
+
*/
|
|
1520
|
+
function packed(items, width) {
|
|
1521
|
+
const lines = [];
|
|
1522
|
+
for (const item of items) {
|
|
1523
|
+
const last = lines.at(-1);
|
|
1524
|
+
if (last !== undefined && `${last} · ${item}`.length <= width) {
|
|
1525
|
+
lines[lines.length - 1] = `${last} · ${item}`;
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
lines.push(item);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return lines;
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* The range's `seam` row: each landed step's commit against that step's own
|
|
1535
|
+
* seam. Null when no step had both a commit and a seam to measure.
|
|
1536
|
+
*
|
|
1537
|
+
* Green counts the steps measured out of the range's steps. One strayed step
|
|
1538
|
+
* is named in the row with its paths as the evidence, the way a step's pause
|
|
1539
|
+
* names them; two or more break onto continuation lines, one per step. It is
|
|
1540
|
+
* an advisory, as at a pause: a stray in a landed commit asks the same
|
|
1541
|
+
* question of the plan.
|
|
1542
|
+
*/
|
|
1543
|
+
function rangeSeamRow(intent, landed, steps) {
|
|
1544
|
+
const measured = landed.flatMap(({ n, files }) => {
|
|
1545
|
+
const seam = parseStepSeam(intent, n);
|
|
1546
|
+
return seam.ok ? [{ n, strays: scopeDrift(files.map((f) => f.path), seam.seam) }] : [];
|
|
1547
|
+
});
|
|
1548
|
+
if (measured.length === 0) {
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
const strayed = measured.filter((m) => m.strays.length > 0);
|
|
1552
|
+
const only = strayed[0];
|
|
1553
|
+
if (only === undefined) {
|
|
1554
|
+
return { verdict: 'true', word: 'held', evidence: `held: ${measured.length} of ${steps} steps, no strays` };
|
|
1555
|
+
}
|
|
1556
|
+
if (strayed.length === 1) {
|
|
1557
|
+
const evidence = `strayed: ${pathCount(only.strays.length)} outside step ${only.n}'s seam`;
|
|
1558
|
+
const path = only.strays[0];
|
|
1559
|
+
return only.strays.length === 1 && path !== undefined
|
|
1560
|
+
? { verdict: 'advisory', word: 'strayed', evidence, pointer: path }
|
|
1561
|
+
: { verdict: 'advisory', word: 'strayed', evidence, items: only.strays };
|
|
1562
|
+
}
|
|
1563
|
+
const total = strayed.reduce((sum, m) => sum + m.strays.length, 0);
|
|
1564
|
+
return {
|
|
1565
|
+
verdict: 'advisory',
|
|
1566
|
+
word: 'strayed',
|
|
1567
|
+
evidence: `strayed: ${pathCount(total)} outside ${strayed.length} steps' seams`,
|
|
1568
|
+
items: strayed.map((m) => strayedStep(m.n, m.strays)),
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
/** A count of paths: `1 path`, `3 paths`. */
|
|
1572
|
+
function pathCount(n) {
|
|
1573
|
+
return `${n} path${n === 1 ? '' : 's'}`;
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* One strayed step's continuation line, `step 8: src/x.ts, src/y.ts`, fitted
|
|
1577
|
+
* to the line by count rather than cut: the first path and how many others,
|
|
1578
|
+
* then the count alone.
|
|
1579
|
+
*/
|
|
1580
|
+
function strayedStep(n, strays) {
|
|
1581
|
+
const width = RECAP_VALUE_BUDGET - 2;
|
|
1582
|
+
const whole = `step ${n}: ${strays.join(', ')}`;
|
|
1583
|
+
if (whole.length <= width) {
|
|
1584
|
+
return whole;
|
|
1585
|
+
}
|
|
1586
|
+
const others = strays.length - 1;
|
|
1587
|
+
const first = `step ${n}: ${strays[0] ?? ''} and ${others} other${others === 1 ? '' : 's'}`;
|
|
1588
|
+
return first.length <= width ? first : `step ${n}: ${pathCount(strays.length)}`;
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* The range's `diff` row: the commits summed, a file counted once however
|
|
1592
|
+
* many commits touched it, plumbbob's own records left out as at a pause.
|
|
1593
|
+
* Information that never folds, and at a stop it never vanishes.
|
|
1594
|
+
*/
|
|
1595
|
+
function rangeDiffValue(files) {
|
|
1596
|
+
const work = files.filter((f) => !isArtifactPath(f.path));
|
|
1597
|
+
const counts = { ...countDiff(work), files: new Set(work.map((f) => f.path)).size };
|
|
1598
|
+
return diffRowValue(counts, false) ?? '+0 -0 across 0 files';
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* The range's `spent` row: from the first step's `startedAt` to the last
|
|
1602
|
+
* step's `landedAt`, then the red runs summed across the steps, or `green
|
|
1603
|
+
* first run at every landing`, then any drift warnings. Null when nothing is
|
|
1604
|
+
* countable.
|
|
1605
|
+
*
|
|
1606
|
+
* The clock reads only the steps `build` entered, since a step landed by hand
|
|
1607
|
+
* has no `startedAt` to count from. No gate clause rides here, because only
|
|
1608
|
+
* the last of the range's gate runs is on disk, and no turn count, because the
|
|
1609
|
+
* ledger counts a step's turns from the tick that entered it.
|
|
1610
|
+
*/
|
|
1611
|
+
function rangeSpentValue(stats) {
|
|
1612
|
+
const entered = stats.filter((s) => s.startedAt !== undefined);
|
|
1613
|
+
const started = Math.min(...entered.map((s) => Date.parse(s.startedAt ?? '')).filter(Number.isFinite));
|
|
1614
|
+
const landed = Math.max(...entered.map((s) => Date.parse(s.landedAt ?? '')).filter(Number.isFinite));
|
|
1615
|
+
const elapsed = Number.isFinite(started) && Number.isFinite(landed) ? durationWords(landed - started) : null;
|
|
1616
|
+
const red = stats.reduce((sum, s) => sum + (s.redChecks ?? 0), 0);
|
|
1617
|
+
const drift = stats.reduce((sum, s) => sum + (s.driftWarnings ?? 0), 0);
|
|
1618
|
+
const parts = [];
|
|
1619
|
+
if (elapsed !== null) {
|
|
1620
|
+
parts.push(elapsed);
|
|
1621
|
+
}
|
|
1622
|
+
if (red > 0) {
|
|
1623
|
+
parts.push(`${red} red run${red === 1 ? '' : 's'}`);
|
|
1624
|
+
}
|
|
1625
|
+
else if (elapsed !== null) {
|
|
1626
|
+
parts.push('green first run at every landing');
|
|
1627
|
+
}
|
|
1628
|
+
if (drift > 0) {
|
|
1629
|
+
parts.push(`${drift} drift warning${drift === 1 ? '' : 's'}`);
|
|
1630
|
+
}
|
|
1631
|
+
return parts.length === 0 ? null : parts.join(' · ');
|
|
1632
|
+
}
|
|
1096
1633
|
// --- The detail file's prose: the Summary that opens a decision turn and the
|
|
1097
1634
|
// recommendation that closes it (docs/presentation.md). The model writes both
|
|
1098
1635
|
// into `.plumbbob/detail.md` and handoff renders them, so the whole turn is one
|
|
@@ -1311,16 +1848,22 @@ export function formatOrientation(o) {
|
|
|
1311
1848
|
* row per range with its command as the plan wrote it and its model, `✓` for
|
|
1312
1849
|
* a range landed and `▸` with the `← next` tail for the one holding the next
|
|
1313
1850
|
* step. An undone range carries its stop beneath it, since the stops ahead are
|
|
1314
|
-
* what the human steers by, and a landed one folds to its row
|
|
1315
|
-
*
|
|
1851
|
+
* what the human steers by, and a landed one folds to its row, because its
|
|
1852
|
+
* call was made. A landed range whose stop card waits is the exception: its
|
|
1853
|
+
* call is still to make, so it keeps its stop and takes the
|
|
1854
|
+
* `← stop card waiting` tail, and the `▸` row drops `← next`, so the block
|
|
1855
|
+
* keeps one arrow. A range's drift rides beneath it, and the plan's own rows
|
|
1856
|
+
* close the block.
|
|
1316
1857
|
*/
|
|
1317
1858
|
function formatRanges(view) {
|
|
1318
1859
|
const landed = view.rows.filter((r) => r.landed).length;
|
|
1860
|
+
const waits = view.rows.some((r) => r.waiting);
|
|
1319
1861
|
const rows = view.rows.flatMap((r) => {
|
|
1320
1862
|
const marker = r.landed ? '✓' : r.next ? '▸' : ' ';
|
|
1321
1863
|
const model = r.range.model === null ? '' : ` · ${r.range.model}`;
|
|
1322
|
-
const
|
|
1323
|
-
const
|
|
1864
|
+
const tail = r.waiting ? ' ← stop card waiting' : r.next && !waits ? ' ← next' : '';
|
|
1865
|
+
const head = ` ${marker} ${r.range.item} ${r.range.command}${model}${tail}`;
|
|
1866
|
+
const stop = r.landed && !r.waiting ? [] : [` stop: ${r.range.stop}`];
|
|
1324
1867
|
return [head, ...stop, ...r.drift.map((words) => ` ⚠ needs a re-cut: ${words}`)];
|
|
1325
1868
|
});
|
|
1326
1869
|
return [` ranges ${landed} of ${view.count} done`, ...rows, ...view.plan.map((words) => ` ⚠ ${words}`)].join('\n');
|