plumbbob 0.12.1 → 0.13.1
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 +20 -13
- package/dist/cli-core.js +22 -6
- package/dist/lib/git.js +20 -8
- package/dist/lib/latch.js +23 -5
- package/dist/lib/notice.js +18 -3
- package/dist/lib/orient.js +350 -34
- package/dist/lib/sidecar.js +202 -3
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/abandon.js +19 -14
- package/dist/verbs/build.js +73 -14
- package/dist/verbs/checkpoint.js +31 -21
- package/dist/verbs/doctor.js +23 -4
- package/dist/verbs/finish.js +21 -17
- package/dist/verbs/handoff.js +259 -80
- package/dist/verbs/order.js +32 -20
- package/dist/verbs/park.js +6 -2
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/relay.js +306 -0
- package/dist/verbs/revert.js +17 -11
- package/dist/verbs/spike.js +50 -38
- package/dist/verbs/start.js +13 -9
- package/dist/verbs/status.js +2 -2
- package/dist/verbs/turn.js +90 -53
- package/dist/verbs/use.js +21 -17
- package/hooks/hooks.json +7 -0
- package/package.json +1 -1
- package/skills/build/SKILL.md +107 -49
- package/skills/harvest/SKILL.md +10 -1
- package/skills/order/SKILL.md +20 -10
- package/skills/park/SKILL.md +7 -0
- package/skills/plan/SKILL.md +52 -7
- package/skills/refine/SKILL.md +21 -3
- package/skills/status/SKILL.md +8 -3
- package/skills/step/SKILL.md +16 -3
- package/skills/verify/SKILL.md +35 -18
- package/templates/intent.md +23 -5
package/dist/lib/sidecar.js
CHANGED
|
@@ -213,6 +213,21 @@ export function stepPath(root, slug) {
|
|
|
213
213
|
export function clearStep(root, slug) {
|
|
214
214
|
rmSync(stepPath(root, slug), { force: true });
|
|
215
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* PAUSE: the stamp of the step pause `handoff` last rendered whole, the step
|
|
218
|
+
* number and a hash of what the card showed. While a later render matches it,
|
|
219
|
+
* handoff prints the one-line reminder instead of the card.
|
|
220
|
+
*/
|
|
221
|
+
export function pausePath(root, slug) {
|
|
222
|
+
return join(artifactDir(root, slug), 'PAUSE');
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Remove the PAUSE stamp: a checkpoint, a revert, or an abandon ends the pause
|
|
226
|
+
* it recorded, so the next pause renders whole. Absent is a no-op.
|
|
227
|
+
*/
|
|
228
|
+
export function clearPause(root, slug) {
|
|
229
|
+
rmSync(pausePath(root, slug), { force: true });
|
|
230
|
+
}
|
|
216
231
|
/**
|
|
217
232
|
* The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
|
|
218
233
|
* commits, one line each.
|
|
@@ -458,6 +473,9 @@ export function clearSpike(root, slug) {
|
|
|
458
473
|
// `plumbbob turn` (the UserPromptSubmit hook) ticks TURN once per human prompt
|
|
459
474
|
// and rewrites GRANT from the literal prompt; the checkpoint latch reads them to
|
|
460
475
|
// refuse a land until a human turn has intervened since the step was entered.
|
|
476
|
+
// A background job's notice reaches the same hook and ticks neither; PROMPT,
|
|
477
|
+
// beside them, records the id of the last prompt the hook saw, so the hook can
|
|
478
|
+
// tell a notice folded into the running turn from one that starts its own.
|
|
461
479
|
// Kept in the sidecar so the lone GRANT `rmSync` stays where deletions of control
|
|
462
480
|
// state are allowed. ---
|
|
463
481
|
/**
|
|
@@ -489,6 +507,41 @@ export function setGrant(root, grant) {
|
|
|
489
507
|
writeFileSync(grantPath(root), `${grant}\n`);
|
|
490
508
|
}
|
|
491
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* PROMPT: the id of the last prompt the turn hook saw, typed or not.
|
|
512
|
+
*/
|
|
513
|
+
export function promptPath(root) {
|
|
514
|
+
return join(root, DIRNAME, 'PROMPT');
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The id of the last prompt the turn hook saw, or null when none is recorded.
|
|
518
|
+
*
|
|
519
|
+
* A notice the harness folds into a running turn arrives under that turn's id,
|
|
520
|
+
* where one that starts a turn of its own arrives under a fresh one, so the
|
|
521
|
+
* hook compares a notice's id against this one to tell the two apart.
|
|
522
|
+
*/
|
|
523
|
+
export function readPrompt(root) {
|
|
524
|
+
try {
|
|
525
|
+
const id = readFileSync(promptPath(root), 'utf8').trim();
|
|
526
|
+
return id === '' ? null : id;
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Record the id of the prompt the hook just saw, or clear the record when the
|
|
534
|
+
* payload carried none, so a stale id can never make a later notice look as
|
|
535
|
+
* though it joined the running turn.
|
|
536
|
+
*/
|
|
537
|
+
export function notePrompt(root, id) {
|
|
538
|
+
if (id === null) {
|
|
539
|
+
rmSync(promptPath(root), { force: true });
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
writeFileSync(promptPath(root), `${id}\n`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
492
545
|
/**
|
|
493
546
|
* TICK: the per-build entry stamp: the TURN value recorded when work was
|
|
494
547
|
* entered (`build <n>` for a step, `start` for the plan), cleared when
|
|
@@ -536,6 +589,143 @@ export function readTurn(root) {
|
|
|
536
589
|
return null;
|
|
537
590
|
}
|
|
538
591
|
}
|
|
592
|
+
// --- The relay record: `.plumbbob/RELAY`, the ending the CLI printed last, so a
|
|
593
|
+
// Stop hook can check that the turn closed on it. The verb that prints an
|
|
594
|
+
// ending records it here: it knows what it printed, where a hook scraping
|
|
595
|
+
// stdout would have to guess which command ran. Flat like TURN, because the
|
|
596
|
+
// turn it is checked against is the session's, not a build's. A later ending
|
|
597
|
+
// overwrites an earlier one, so a turn is judged by the last thing it printed. ---
|
|
598
|
+
// The tiers of docs/presentation.md, which decide how a turn has to carry an
|
|
599
|
+
// ending: a decision ending closes the turn, an orientation ending appears in
|
|
600
|
+
// it, and a driver ending is recorded only so that the last ending wins.
|
|
601
|
+
const RELAY_TIERS = ['decision', 'orientation', 'driver'];
|
|
602
|
+
// Which ending printed: handoff's six forms first, then one per transition. It
|
|
603
|
+
// is what the check's reason names when the ending went missing.
|
|
604
|
+
const RELAY_RENDERS = [
|
|
605
|
+
'card',
|
|
606
|
+
'reminder',
|
|
607
|
+
'plan',
|
|
608
|
+
'boundary',
|
|
609
|
+
'pointer',
|
|
610
|
+
'driver',
|
|
611
|
+
'checkpoint',
|
|
612
|
+
'plan-commit',
|
|
613
|
+
'park',
|
|
614
|
+
'revert',
|
|
615
|
+
'abandon',
|
|
616
|
+
'spike-open',
|
|
617
|
+
'spike-report',
|
|
618
|
+
'spike-done',
|
|
619
|
+
'use',
|
|
620
|
+
'order',
|
|
621
|
+
'finish',
|
|
622
|
+
'start',
|
|
623
|
+
];
|
|
624
|
+
/**
|
|
625
|
+
* RELAY: the ending the CLI printed last, at the flat sidecar root.
|
|
626
|
+
*/
|
|
627
|
+
export function relayPath(root) {
|
|
628
|
+
return join(root, DIRNAME, 'RELAY');
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Record an ending in RELAY, stamped with the current TURN: a header of
|
|
632
|
+
* `key value` lines, a blank line, then the text exactly as it printed.
|
|
633
|
+
*
|
|
634
|
+
* Written only while TURN holds a count, the same dormancy `stampTick` keeps: a
|
|
635
|
+
* host with no turn hook has no turn to check the ending against. Best-effort
|
|
636
|
+
* by contract, since the ending has already reached stdout and a failed record
|
|
637
|
+
* must not change it or the exit code.
|
|
638
|
+
*/
|
|
639
|
+
export function writeRelay(root, relay) {
|
|
640
|
+
const turn = readTurn(root);
|
|
641
|
+
if (turn === null)
|
|
642
|
+
return;
|
|
643
|
+
// A hold the check already spent on this turn survives the ending the retry
|
|
644
|
+
// reprints, or a turn whose retry missed again would be held a second time.
|
|
645
|
+
const held = readRelay(root)?.held === turn ? { held: turn } : {};
|
|
646
|
+
try {
|
|
647
|
+
writeFileSync(relayPath(root), formatRelay({ ...relay, turn, ...held }));
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
// A read-only tree or a directory in the way: the ending already printed.
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Drop the recorded ending, so the `Stop` check has nothing to judge until a
|
|
655
|
+
* verb prints another.
|
|
656
|
+
*
|
|
657
|
+
* The turn hook calls this when a background job's notice starts a turn of its
|
|
658
|
+
* own. The ending was judged when its own turn ended, and a notice leaves TURN
|
|
659
|
+
* where it was, so without this the notice's turn would read as the one that
|
|
660
|
+
* printed the ending and be held open for a card the human has already read.
|
|
661
|
+
*/
|
|
662
|
+
export function clearRelay(root) {
|
|
663
|
+
rmSync(relayPath(root), { force: true });
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Stamp the record with the turn the check held open over it, so the check
|
|
667
|
+
* holds at most once per turn. False when the stamp could not be written, and
|
|
668
|
+
* the check then holds nothing, since a hold it cannot count could repeat.
|
|
669
|
+
*/
|
|
670
|
+
export function holdRelay(root, record) {
|
|
671
|
+
try {
|
|
672
|
+
writeFileSync(relayPath(root), formatRelay({ ...record, held: record.turn }));
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/** A record as RELAY stores it: the header's `key value` lines, a blank line, then the text. */
|
|
680
|
+
function formatRelay(record) {
|
|
681
|
+
const header = [`turn ${record.turn}`, `tier ${record.tier}`, `render ${record.render}`];
|
|
682
|
+
if (record.command !== undefined)
|
|
683
|
+
header.push(`command ${record.command}`);
|
|
684
|
+
if (record.held !== undefined)
|
|
685
|
+
header.push(`held ${record.held}`);
|
|
686
|
+
return `${header.join('\n')}\n\n${record.text}`;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* The recorded ending, or null when RELAY is absent or its header does not
|
|
690
|
+
* parse: a record the check cannot read is no record.
|
|
691
|
+
*/
|
|
692
|
+
export function readRelay(root) {
|
|
693
|
+
let raw;
|
|
694
|
+
try {
|
|
695
|
+
raw = readFileSync(relayPath(root), 'utf8');
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
const split = raw.indexOf('\n\n');
|
|
701
|
+
if (split === -1)
|
|
702
|
+
return null;
|
|
703
|
+
const fields = new Map();
|
|
704
|
+
for (const line of raw.slice(0, split).split('\n')) {
|
|
705
|
+
const space = line.indexOf(' ');
|
|
706
|
+
if (space > 0)
|
|
707
|
+
fields.set(line.slice(0, space), line.slice(space + 1));
|
|
708
|
+
}
|
|
709
|
+
const turn = count(fields.get('turn'));
|
|
710
|
+
const tier = RELAY_TIERS.find((t) => t === fields.get('tier'));
|
|
711
|
+
const render = RELAY_RENDERS.find((r) => r === fields.get('render'));
|
|
712
|
+
if (turn === null || tier === undefined || render === undefined)
|
|
713
|
+
return null;
|
|
714
|
+
const command = fields.get('command');
|
|
715
|
+
const held = count(fields.get('held'));
|
|
716
|
+
return {
|
|
717
|
+
turn,
|
|
718
|
+
tier,
|
|
719
|
+
render,
|
|
720
|
+
...(command === undefined ? {} : { command }),
|
|
721
|
+
...(held === null ? {} : { held }),
|
|
722
|
+
text: raw.slice(split + 2),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
/** A header field read as a bare count, or null when it is absent or anything else. */
|
|
726
|
+
function count(field) {
|
|
727
|
+
return field !== undefined && /^\d+$/.test(field) ? Number(field) : null;
|
|
728
|
+
}
|
|
539
729
|
/**
|
|
540
730
|
* Walk up from `cwd` to the nearest ancestor with an active session (a
|
|
541
731
|
* `.plumbbob/STATE` file), or null when there is none.
|
|
@@ -599,17 +789,26 @@ export function excludeControl(root) {
|
|
|
599
789
|
// never writes, and never commits.
|
|
600
790
|
`${DIRNAME}/TURN`,
|
|
601
791
|
`${DIRNAME}/GRANT`,
|
|
792
|
+
// The id of the last prompt the turn hook saw, which tells a notice folded
|
|
793
|
+
// into the running turn from one that starts its own.
|
|
794
|
+
`${DIRNAME}/PROMPT`,
|
|
602
795
|
// The in-flight step's detail file: flat per-session control the model
|
|
603
|
-
// overwrites before every pause. checkpoint
|
|
604
|
-
// and then clears it, so its archive is the
|
|
605
|
-
// excluding it keeps stageAll from ever sweeping it into a step commit.
|
|
796
|
+
// overwrites before every pause. checkpoint records it in the build-log's
|
|
797
|
+
// Log and then clears it, so its archive is the tracked ledger, never this
|
|
798
|
+
// path; excluding it keeps stageAll from ever sweeping it into a step commit.
|
|
606
799
|
`${DIRNAME}/detail.md`,
|
|
800
|
+
// The ending the CLI printed last, for the Stop check: per-worktree
|
|
801
|
+
// control, rewritten by every ending, and never a step commit's to carry.
|
|
802
|
+
`${DIRNAME}/RELAY`,
|
|
607
803
|
`${DIRNAME}/builds/*/STEP`,
|
|
608
804
|
`${DIRNAME}/builds/*/SEAM`,
|
|
609
805
|
`${DIRNAME}/builds/*/SPIKE`,
|
|
610
806
|
// The entry stamp is in-flight control like STEP/SEAM: checkpoint's
|
|
611
807
|
// stageAll must never sweep it into a step commit.
|
|
612
808
|
`${DIRNAME}/builds/*/TICK`,
|
|
809
|
+
// The pause stamp is in-flight control too: what handoff last showed, so
|
|
810
|
+
// a repeat prints the reminder. It must never ride a step commit.
|
|
811
|
+
`${DIRNAME}/builds/*/PAUSE`,
|
|
613
812
|
// The agent-run handoff ledger is step-scoped in-flight state, not a
|
|
614
813
|
// tracked artifact: it must never ride a step commit into the PR.
|
|
615
814
|
`${DIRNAME}/builds/*/handoff.json`,
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// A step expression: the one grammar a `/plumbbob:build` range is typed in. The
|
|
2
|
+
// turn hook reads it to mint a grant, the latch to land exactly what the grant
|
|
3
|
+
// names, `build` to say where a range stops, and the dashboard to point at where
|
|
4
|
+
// one starts, so no two of them can read the same keystrokes two ways. An
|
|
5
|
+
// expression is one number, a span `N-M` with N no greater than M, or numbers
|
|
6
|
+
// and spans joined by commas (`20,5-9`). It names plan numbers, never positions
|
|
7
|
+
// in the build order, and it is held as the spans it was written in, so
|
|
8
|
+
// `1-100000` stays two numbers rather than a hundred thousand. Pure: no fs.
|
|
9
|
+
/**
|
|
10
|
+
* Parse a step expression, or null when it is anything else.
|
|
11
|
+
*
|
|
12
|
+
* Every item is a positive number or a span whose first number is no greater
|
|
13
|
+
* than its last. An empty item, a stray dash, or a space inside the expression
|
|
14
|
+
* reads as no list at all, so a malformed range names nothing and grants
|
|
15
|
+
* nothing.
|
|
16
|
+
*/
|
|
17
|
+
export function parseStepList(text) {
|
|
18
|
+
const spans = [];
|
|
19
|
+
for (const item of text.split(',')) {
|
|
20
|
+
const m = /^(\d+)(?:-(\d+))?$/.exec(item);
|
|
21
|
+
if (m === null)
|
|
22
|
+
return null;
|
|
23
|
+
const from = Number(m[1]);
|
|
24
|
+
const to = m[2] === undefined ? from : Number(m[2]);
|
|
25
|
+
if (!Number.isSafeInteger(to) || from < 1 || to < from)
|
|
26
|
+
return null;
|
|
27
|
+
spans.push({ from, to });
|
|
28
|
+
}
|
|
29
|
+
return spans;
|
|
30
|
+
}
|
|
31
|
+
/** Whether a list names step `n`. */
|
|
32
|
+
export function hasStep(list, n) {
|
|
33
|
+
return list.some((span) => n >= span.from && n <= span.to);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Write steps back as an expression in the order given, folding each run that
|
|
37
|
+
* climbs by one into a span: `[20, 5, 6, 7, 8, 9]` writes `20,5-9`.
|
|
38
|
+
*
|
|
39
|
+
* The order is the caller's, because a list has none of its own: the card
|
|
40
|
+
* writes one in build order, so its first number is where the range starts.
|
|
41
|
+
*/
|
|
42
|
+
export function formatStepList(steps) {
|
|
43
|
+
const runs = [];
|
|
44
|
+
for (const n of steps) {
|
|
45
|
+
const last = runs.at(-1);
|
|
46
|
+
if (last !== undefined && n === last.to + 1) {
|
|
47
|
+
last.to = n;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
runs.push({ from: n, to: n });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return runs.map(spanText).join(',');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A list's normal form: its spans sorted and merged, so two ways of typing the
|
|
57
|
+
* same steps write the same line (`20, 5-9` and `5-9,20` both write `5-9,20`).
|
|
58
|
+
*
|
|
59
|
+
* The grant stores this form and never the expansion, so a range of any length
|
|
60
|
+
* is one short line.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeStepList(list) {
|
|
63
|
+
const merged = [];
|
|
64
|
+
for (const span of [...list].sort((a, b) => a.from - b.from)) {
|
|
65
|
+
const last = merged.at(-1);
|
|
66
|
+
if (last !== undefined && span.from <= last.to + 1) {
|
|
67
|
+
last.to = Math.max(last.to, span.to);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
merged.push({ from: span.from, to: span.to });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return merged.map(spanText).join(',');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The numbers a list names, in the order written, each span walked only while
|
|
77
|
+
* `known` holds and stopped at the first number it does not, which is kept.
|
|
78
|
+
*
|
|
79
|
+
* A build order is a sequence rather than a set, so its spans expand in place
|
|
80
|
+
* (`1,6,2-4` names 1, 6, 2, 3, 4). The number kept past the plan is the one a
|
|
81
|
+
* caller reports the plan lacks, and stopping there means `2-100000` costs one
|
|
82
|
+
* number past the last planned step rather than a hundred thousand.
|
|
83
|
+
*/
|
|
84
|
+
export function expandStepList(list, known) {
|
|
85
|
+
const numbers = [];
|
|
86
|
+
for (const span of list) {
|
|
87
|
+
for (let n = span.from; n <= span.to; n += 1) {
|
|
88
|
+
numbers.push(n);
|
|
89
|
+
if (!known(n))
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return numbers;
|
|
94
|
+
}
|
|
95
|
+
/** One span as it is typed: a lone number, or `from-to`. */
|
|
96
|
+
function spanText(span) {
|
|
97
|
+
return span.from === span.to ? String(span.from) : `${span.from}-${span.to}`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read the arguments a build invocation carries, up to the first token that is
|
|
101
|
+
* not one: `--auto`, or a run of digits, commas, and dashes.
|
|
102
|
+
*
|
|
103
|
+
* Once any number carries a comma or a dash, every number and span in the run
|
|
104
|
+
* joins one list, so `20, 5-9` typed with a space reads as `20,5-9`. Trailing
|
|
105
|
+
* sentence punctuation is the sentence's (`/build 1-3.`), and so is a comma
|
|
106
|
+
* after the run's last number (`/build 2, then review`), which separates
|
|
107
|
+
* nothing.
|
|
108
|
+
*/
|
|
109
|
+
export function readBuildArgs(text) {
|
|
110
|
+
let auto = false;
|
|
111
|
+
const words = [];
|
|
112
|
+
for (const token of text.split(/\s+/)) {
|
|
113
|
+
const word = token.replace(/[.;:!?]+$/, '');
|
|
114
|
+
if (word === '')
|
|
115
|
+
continue;
|
|
116
|
+
if (word === '--auto') {
|
|
117
|
+
auto = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!/^[\d,-]+$/.test(word))
|
|
121
|
+
break;
|
|
122
|
+
words.push(word);
|
|
123
|
+
}
|
|
124
|
+
const items = words.map((w, i) => (i === words.length - 1 ? w.replace(/,+$/, '') : w)).filter((w) => w !== '');
|
|
125
|
+
const range = items.some((w) => /[,-]/.test(w));
|
|
126
|
+
const steps = items.length === 0 ? null : parseStepList(items.join(',').replace(/,{2,}/g, ','));
|
|
127
|
+
return { auto, range, steps };
|
|
128
|
+
}
|
package/dist/verbs/abandon.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// `plumbbob abandon`: the third exit from an in-flight step. Checkpoint lands
|
|
2
2
|
// the step and revert destroys the work; abandon drops the attempt but keeps the
|
|
3
3
|
// working-tree diff exactly where it is. Its only writes are the in-flight
|
|
4
|
-
// control markers it clears (STEP, SEAM, TICK, and the
|
|
5
|
-
// ledger), a build-log line, and the stats receipt: it
|
|
6
|
-
// working tree nor git nor the intent checkbox, so the step
|
|
7
|
-
// re-buildable with its diff still in the tree for the human
|
|
8
|
-
// commit by hand.
|
|
4
|
+
// control markers it clears (STEP, SEAM, TICK, the PAUSE stamp, and the
|
|
5
|
+
// step-scoped handoff ledger), a build-log line, and the stats receipt: it
|
|
6
|
+
// touches neither the working tree nor git nor the intent checkbox, so the step
|
|
7
|
+
// stays planned and re-buildable with its diff still in the tree for the human
|
|
8
|
+
// to keep, rework, or commit by hand.
|
|
9
9
|
//
|
|
10
10
|
// A step exit is a boundary crossing, so abandon honors the same approval latch
|
|
11
11
|
// as checkpoint. That is not decoration: abandon clears the entry stamp the
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
// control-state clears; abandon itself never calls the raw fs deleters.
|
|
16
16
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
17
17
|
import { findRepoRoot } from "../lib/git.js";
|
|
18
|
-
import { bumpStepStat, buildLogPath, clearHandoff, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
|
|
18
|
+
import { bumpStepStat, buildLogPath, clearHandoff, clearPause, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
|
|
19
19
|
import { checkLatch } from "../lib/latch.js";
|
|
20
20
|
import { abandonLogLine, appendToSection } from "../lib/buildlog.js";
|
|
21
21
|
import { parseSteps } from "../lib/orient.js";
|
|
22
|
-
import { ending, notice, transition } from "../lib/notice.js";
|
|
22
|
+
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
23
23
|
import { driverPointer } from "./handoff.js";
|
|
24
24
|
/**
|
|
25
25
|
* Drop the in-flight step while keeping its work: latch, clear the markers,
|
|
@@ -55,18 +55,23 @@ export function abandon(cwd, args) {
|
|
|
55
55
|
clearSeam(root, slug);
|
|
56
56
|
clearTick(root, slug);
|
|
57
57
|
clearHandoff(root, slug);
|
|
58
|
+
clearPause(root, slug);
|
|
58
59
|
bumpStepStat(root, slug, step, 'abandons');
|
|
59
60
|
logAbandon(root, slug, step);
|
|
60
61
|
// A step exit: with the step back at `[ ]` and its markers gone, the pointer
|
|
61
62
|
// aims forward from the boundary, at the very step just dropped.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
printEnding(root, {
|
|
64
|
+
tier: 'driver',
|
|
65
|
+
render: 'abandon',
|
|
66
|
+
text: ending({
|
|
67
|
+
lead: transition({
|
|
68
|
+
label: 'Abandoned',
|
|
69
|
+
fact: `Step ${step}`,
|
|
70
|
+
detail: ['work kept in the tree', 'the step stays planned'],
|
|
71
|
+
}),
|
|
72
|
+
pointer: driverPointer(root, slug),
|
|
67
73
|
}),
|
|
68
|
-
|
|
69
|
-
}));
|
|
74
|
+
});
|
|
70
75
|
return 0;
|
|
71
76
|
}
|
|
72
77
|
/**
|
package/dist/verbs/build.js
CHANGED
|
@@ -8,18 +8,21 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
8
8
|
import { findRepoRoot } from "../lib/git.js";
|
|
9
9
|
import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTick, stepPath } from "../lib/sidecar.js";
|
|
10
10
|
import { parseStepSeam } from "../lib/intent.js";
|
|
11
|
-
import { parseOrderedSteps, skippedBefore } from "../lib/orient.js";
|
|
11
|
+
import { parseBuildRanges, parseOrderedSteps, rangeStanding, rangeStart, skippedBefore } from "../lib/orient.js";
|
|
12
12
|
import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
13
13
|
import { readGrant } from "../lib/latch.js";
|
|
14
14
|
import { notice } from "../lib/notice.js";
|
|
15
|
+
import { hasStep, readBuildArgs } from "../lib/steplist.js";
|
|
15
16
|
/**
|
|
16
17
|
* Enter step n (or the next undone step) and write its SEAM/STEP markers.
|
|
17
18
|
*
|
|
18
|
-
* Refuses a missing session, a malformed step argument,
|
|
19
|
-
* `/plumbbob:build` skill feature, not a CLI one), and a seam
|
|
19
|
+
* Refuses a missing session, a malformed step argument, a range such as `N-M`
|
|
20
|
+
* or `20,5-9` (a `/plumbbob:build` skill feature, not a CLI one), and a seam
|
|
21
|
+
* that fails to parse.
|
|
20
22
|
* On entry it stamps the turn ledger and the step's start time, re-renders the
|
|
21
23
|
* build-log's Current step line, and names the step when it is the last one a
|
|
22
|
-
* self-approval grant reaches
|
|
24
|
+
* self-approval grant reaches, or, under `--auto`, a call the plan's ranges
|
|
25
|
+
* reserve for the human.
|
|
23
26
|
*/
|
|
24
27
|
export function build(cwd, args) {
|
|
25
28
|
const root = findRepoRoot(cwd);
|
|
@@ -29,15 +32,15 @@ export function build(cwd, args) {
|
|
|
29
32
|
}
|
|
30
33
|
const { build: slug, rest } = resolveBuild(root, args);
|
|
31
34
|
const raw = rest.find((a) => !a.startsWith('--'));
|
|
32
|
-
// A step range like `1-3` is a `/plumbbob:build` skill affordance
|
|
33
|
-
//
|
|
35
|
+
// A step range like `1-3` or `20,5-9` is a `/plumbbob:build` skill affordance
|
|
36
|
+
// (auto-approve each step it names, then stop), not a CLI capability: the CLI
|
|
34
37
|
// records one in-flight step at a time. Name it rather than bounce off the
|
|
35
|
-
// generic usage.
|
|
36
|
-
if (raw !== undefined && /^\d
|
|
38
|
+
// generic usage, and point at the step the range starts at.
|
|
39
|
+
if (raw !== undefined && /^\d[\d,-]*$/.test(raw) && /[,-]/.test(raw)) {
|
|
37
40
|
process.stderr.write(notice({
|
|
38
41
|
fact: 'build takes one step number',
|
|
39
42
|
detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through its top and then stops`],
|
|
40
|
-
remedy: `plumbbob build ${
|
|
43
|
+
remedy: `plumbbob build ${rangeEntry(intentPath(root, slug), rest.join(' '), raw)}`,
|
|
41
44
|
}));
|
|
42
45
|
return 1;
|
|
43
46
|
}
|
|
@@ -100,11 +103,15 @@ export function build(cwd, args) {
|
|
|
100
103
|
: skipped > 0
|
|
101
104
|
? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
|
|
102
105
|
: [];
|
|
103
|
-
// Two lines (three on the last step a grant reaches
|
|
104
|
-
// the seam as a plain readout beneath the
|
|
105
|
-
// are a list, and a list is not a one-liner.
|
|
106
|
+
// Two lines (three on the last step a grant reaches, or on a call the plan
|
|
107
|
+
// reserves), one colon each, then the seam as a plain readout beneath the
|
|
108
|
+
// notice that frames it: the paths are a list, and a list is not a one-liner.
|
|
109
|
+
// A reserved call outranks the last-step line, since the step stops at its
|
|
110
|
+
// pause rather than landing.
|
|
111
|
+
const grant = readGrant(root);
|
|
112
|
+
const reserved = grant?.kind === 'auto' ? reservedCallNotice(intent, steps, step) : '';
|
|
106
113
|
process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
|
|
107
|
-
lastGrantedNotice(
|
|
114
|
+
(reserved.length > 0 ? reserved : lastGrantedNotice(grant, steps, step)) +
|
|
108
115
|
notice({
|
|
109
116
|
fact: 'the seam is orientation, not a lock',
|
|
110
117
|
detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
|
|
@@ -112,6 +119,45 @@ export function build(cwd, args) {
|
|
|
112
119
|
`${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
|
|
113
120
|
return 0;
|
|
114
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The step a range typed at the CLI starts at: its first undone step in build
|
|
124
|
+
* order when the plan reads, else its first number written.
|
|
125
|
+
*
|
|
126
|
+
* Best-effort, since it only fills the refusal's remedy: a plan that cannot be
|
|
127
|
+
* read, or a range that does not parse, still points at a number the human typed.
|
|
128
|
+
*/
|
|
129
|
+
function rangeEntry(intent, args, raw) {
|
|
130
|
+
const list = readBuildArgs(args).steps;
|
|
131
|
+
try {
|
|
132
|
+
const start = list === null ? null : rangeStart(parseOrderedSteps(readFileSync(intent, 'utf8')), list);
|
|
133
|
+
if (start !== null)
|
|
134
|
+
return String(start);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// No plan to order by: fall back to the number typed first.
|
|
138
|
+
}
|
|
139
|
+
return /\d+/.exec(raw)?.[0] ?? '1';
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The line saying a step ends at its pause under `--auto`, because the plan's
|
|
143
|
+
* build ranges name it as a bare range: a call the plan reserves for the
|
|
144
|
+
* human. '' for any other step.
|
|
145
|
+
*
|
|
146
|
+
* The halt is this line's and the skill's, never the latch's: the ranges are
|
|
147
|
+
* advice, so `--auto` still grants the checkpoint, and saying so as the step
|
|
148
|
+
* is entered is what puts the stop in front of the model before it lands one.
|
|
149
|
+
*/
|
|
150
|
+
function reservedCallNotice(intent, steps, step) {
|
|
151
|
+
const { count, holding } = rangeStanding(parseBuildRanges(intent), steps, step);
|
|
152
|
+
if (holding === null || !holding.bare) {
|
|
153
|
+
return '';
|
|
154
|
+
}
|
|
155
|
+
return notice({
|
|
156
|
+
fact: `step ${step} ends at its pause, where the plan reserves the call for the human`,
|
|
157
|
+
detail: [`range ${holding.item} of ${count}`],
|
|
158
|
+
remedy: 'hold it there for their approval, --auto or not',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
115
161
|
/**
|
|
116
162
|
* The line naming the last step a self-approval grant reaches this turn, or ''
|
|
117
163
|
* for any other step and when no grant is live.
|
|
@@ -123,13 +169,26 @@ export function build(cwd, args) {
|
|
|
123
169
|
* reaches its top, or the last undone step under its top when the plan ends
|
|
124
170
|
* first, or when the build order puts a number past the top ahead of the rest
|
|
125
171
|
* (a range names plan numbers, and the skill halts before one past its top);
|
|
126
|
-
* `--auto` reaches the plan's last undone step.
|
|
172
|
+
* `--auto` reaches the plan's last undone step. A range that lists its steps
|
|
173
|
+
* walks them in build order, so its top is the last undone step it names, and
|
|
174
|
+
* it reaches no further than a step it does not name.
|
|
127
175
|
*/
|
|
128
176
|
function lastGrantedNotice(grant, steps, step) {
|
|
129
177
|
if (grant === null) {
|
|
130
178
|
return '';
|
|
131
179
|
}
|
|
132
180
|
const remedy = 'land it on green, then stop at the boundary';
|
|
181
|
+
if (grant.kind === 'steps') {
|
|
182
|
+
if (!hasStep(grant.steps, step))
|
|
183
|
+
return '';
|
|
184
|
+
const rest = steps.slice(steps.findIndex((s) => s.n === step) + 1).filter((s) => !s.done && s.n !== step);
|
|
185
|
+
const after = rest[0];
|
|
186
|
+
if (after !== undefined && hasStep(grant.steps, after.n))
|
|
187
|
+
return '';
|
|
188
|
+
return rest.some((s) => hasStep(grant.steps, s.n))
|
|
189
|
+
? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
|
|
190
|
+
: notice({ fact: `step ${step} is the top of the range you granted`, remedy });
|
|
191
|
+
}
|
|
133
192
|
if (grant.kind === 'range') {
|
|
134
193
|
if (step === grant.ceiling) {
|
|
135
194
|
return notice({ fact: `step ${step} is the top of the range you granted`, remedy });
|
package/dist/verbs/checkpoint.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { relative } from 'node:path';
|
|
14
14
|
import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
|
|
15
|
-
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
|
|
15
|
+
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearPause, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
|
|
16
16
|
import { runCheck } from "../lib/check.js";
|
|
17
17
|
import { readCommitBody } from "../lib/commitbody.js";
|
|
18
18
|
import { checkLatch } from "../lib/latch.js";
|
|
@@ -21,7 +21,7 @@ import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
|
|
|
21
21
|
import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
|
|
22
22
|
import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
|
|
23
23
|
import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
24
|
-
import { advisory, ending, notice, transition } from "../lib/notice.js";
|
|
24
|
+
import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
25
25
|
import { boundaryEnding, planRecord, stepRecord } from "./handoff.js";
|
|
26
26
|
/**
|
|
27
27
|
* Land a step: latch, check gate, commit, record, return to the boundary.
|
|
@@ -116,17 +116,23 @@ export async function checkpoint(cwd, args) {
|
|
|
116
116
|
rmSync(stepPath(root), { force: true });
|
|
117
117
|
clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
|
|
118
118
|
clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
|
|
119
|
+
clearPause(root); // the pause it stamped is over; the next step's pause renders whole.
|
|
119
120
|
// The whole ending, one block: the lead line, the Verdict folded over what
|
|
120
121
|
// just landed, the advisories this checkpoint alone can state, and the
|
|
121
122
|
// pointer. Read after the flip and the ledger append, so the fold and the
|
|
122
123
|
// pointer both see the boundary the human is standing at.
|
|
123
124
|
const landed = boundaryEnding(root, activeBuild(root), step);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
printEnding(root, {
|
|
126
|
+
tier: 'orientation',
|
|
127
|
+
render: 'checkpoint',
|
|
128
|
+
text: ending({
|
|
129
|
+
lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
|
|
130
|
+
verdict: landed.verdict,
|
|
131
|
+
advisories: [drift, flipNotice],
|
|
132
|
+
stop: landed.stop,
|
|
133
|
+
pointer: landed.pointer,
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
130
136
|
return 0;
|
|
131
137
|
}
|
|
132
138
|
/**
|
|
@@ -172,19 +178,23 @@ function checkpointPlan(root, args) {
|
|
|
172
178
|
clearTick(root);
|
|
173
179
|
// No Verdict: the plan commit measures nothing. The pointer aims at the step
|
|
174
180
|
// the build starts on, which is what `handoff` renders from a null step.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
181
|
+
printEnding(root, {
|
|
182
|
+
tier: 'orientation',
|
|
183
|
+
render: 'plan-commit',
|
|
184
|
+
text: ending({
|
|
185
|
+
lead: transition({ label: 'Plan', fact: 'committed', detail: leadDetail(sha, pointer) }),
|
|
186
|
+
advisories: staged
|
|
187
|
+
? []
|
|
188
|
+
: [
|
|
189
|
+
advisory({
|
|
190
|
+
fact: 'the plan rides the commit message',
|
|
191
|
+
detail: ['record-only', '.plumbbob/ is gitignored, so the files stay untracked'],
|
|
192
|
+
remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
|
|
193
|
+
}),
|
|
194
|
+
],
|
|
195
|
+
pointer: boundaryEnding(root, activeBuild(root), null).pointer,
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
188
198
|
return 0;
|
|
189
199
|
}
|
|
190
200
|
/**
|