plumbbob 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +75 -54
- package/dist/cli-core.js +29 -9
- package/dist/lib/buildlog.js +77 -0
- package/dist/lib/git.js +53 -0
- package/dist/lib/latch.js +46 -5
- package/dist/lib/notice.js +3 -2
- package/dist/lib/orient.js +878 -61
- package/dist/lib/sidecar.js +164 -3
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/build.js +108 -14
- package/dist/verbs/checkpoint.js +41 -13
- package/dist/verbs/handoff.js +508 -42
- package/dist/verbs/harvest.js +224 -0
- package/dist/verbs/order.js +15 -11
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/relay.js +15 -2
- package/dist/verbs/status.js +64 -17
- package/dist/verbs/turn.js +170 -58
- package/package.json +1 -1
- package/skills/build/SKILL.md +85 -34
- package/skills/finish/SKILL.md +18 -4
- package/skills/harvest/SKILL.md +54 -11
- package/skills/order/SKILL.md +20 -10
- package/skills/plan/SKILL.md +77 -17
- package/skills/refine/SKILL.md +24 -3
- package/skills/run/SKILL.md +256 -0
- package/skills/status/SKILL.md +10 -3
- package/skills/step/SKILL.md +25 -3
- package/skills/verify/SKILL.md +6 -2
- package/templates/intent.md +30 -8
package/dist/lib/sidecar.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// human-owned (check/auto): the tool only ever reads it, never rewrites it. The
|
|
19
19
|
// phase the dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored: BUILD ⇔
|
|
20
20
|
// a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
|
|
21
|
+
import { createHash } from 'node:crypto';
|
|
21
22
|
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
22
23
|
import { dirname, join } from 'node:path';
|
|
23
24
|
import { gitPath } from "./git.js";
|
|
@@ -228,6 +229,61 @@ export function pausePath(root, slug) {
|
|
|
228
229
|
export function clearPause(root, slug) {
|
|
229
230
|
rmSync(pausePath(root, slug), { force: true });
|
|
230
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* STOP: the stamp of the stop card `handoff --stop` last rendered whole, the
|
|
234
|
+
* range it reviews, the TURN it printed in, a hash of what the card showed,
|
|
235
|
+
* and a digest of the detail file it printed from. While a later render
|
|
236
|
+
* matches the range and the hash, handoff prints the one-line reminder;
|
|
237
|
+
* `plumbbob harvest` records the stop only from the detail the card showed,
|
|
238
|
+
* and only once a human turn has read it.
|
|
239
|
+
*/
|
|
240
|
+
export function stopPath(root, slug) {
|
|
241
|
+
return join(artifactDir(root, slug), 'STOP');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The stamp of the stop card last rendered, or null when STOP is absent or
|
|
245
|
+
* does not parse.
|
|
246
|
+
*/
|
|
247
|
+
export function readStop(root, slug) {
|
|
248
|
+
try {
|
|
249
|
+
const m = /^(\d+) (\d+|-) (\S+)(?: (\S+))?$/.exec(readFileSync(stopPath(root, slug), 'utf8').trim());
|
|
250
|
+
if (m === null)
|
|
251
|
+
return null;
|
|
252
|
+
return { range: Number(m[1]), turn: m[2] === '-' ? null : Number(m[2]), hash: m[3] ?? '', detail: m[4] ?? null };
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Record the stamp of the stop card just printed, with the digest of the
|
|
260
|
+
* detail file as it stands, which is the file the card was rendered from.
|
|
261
|
+
* Best-effort: a failed write costs one more full card on a later turn, never
|
|
262
|
+
* this turn's output or its exit code.
|
|
263
|
+
*/
|
|
264
|
+
export function writeStop(root, slug, stamp) {
|
|
265
|
+
try {
|
|
266
|
+
const detail = detailDigest(readFileSync(detailPath(root), 'utf8'));
|
|
267
|
+
writeFileSync(stopPath(root, slug), `${stamp.range} ${stamp.turn ?? '-'} ${stamp.hash} ${detail}\n`);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// A read-only tree or a vanished build folder: the card still renders.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Remove the STOP stamp: the stop's record ends the card it recorded. Absent
|
|
275
|
+
* is a no-op.
|
|
276
|
+
*/
|
|
277
|
+
export function clearStop(root, slug) {
|
|
278
|
+
rmSync(stopPath(root, slug), { force: true });
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The digest a STOP stamp keeps of a detail file, so the record can tell the
|
|
282
|
+
* detail a card showed from one written after it.
|
|
283
|
+
*/
|
|
284
|
+
export function detailDigest(detail) {
|
|
285
|
+
return createHash('sha256').update(detail).digest('hex');
|
|
286
|
+
}
|
|
231
287
|
/**
|
|
232
288
|
* The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
|
|
233
289
|
* commits, one line each.
|
|
@@ -473,6 +529,9 @@ export function clearSpike(root, slug) {
|
|
|
473
529
|
// `plumbbob turn` (the UserPromptSubmit hook) ticks TURN once per human prompt
|
|
474
530
|
// and rewrites GRANT from the literal prompt; the checkpoint latch reads them to
|
|
475
531
|
// refuse a land until a human turn has intervened since the step was entered.
|
|
532
|
+
// A background job's notice reaches the same hook and ticks neither; PROMPT,
|
|
533
|
+
// beside them, records the id of the last prompt the hook saw, so the hook can
|
|
534
|
+
// tell a notice folded into the running turn from one that starts its own.
|
|
476
535
|
// Kept in the sidecar so the lone GRANT `rmSync` stays where deletions of control
|
|
477
536
|
// state are allowed. ---
|
|
478
537
|
/**
|
|
@@ -489,7 +548,7 @@ export function grantPath(root) {
|
|
|
489
548
|
}
|
|
490
549
|
/**
|
|
491
550
|
* Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
|
|
492
|
-
* (`auto` | `
|
|
551
|
+
* (`auto` | `steps <list>`), null clears it.
|
|
493
552
|
*
|
|
494
553
|
* The turn hook calls this on every human prompt (minted on a match, cleared
|
|
495
554
|
* otherwise) so a grant lives exactly one turn by construction. Pairing the
|
|
@@ -504,6 +563,75 @@ export function setGrant(root, grant) {
|
|
|
504
563
|
writeFileSync(grantPath(root), `${grant}\n`);
|
|
505
564
|
}
|
|
506
565
|
}
|
|
566
|
+
/**
|
|
567
|
+
* RUN: the range a `/plumbbob:run` the human typed is landing, written beside
|
|
568
|
+
* GRANT by the turn hook so the checkpoint that completes the range knows a
|
|
569
|
+
* run landed it. The latch never reads it.
|
|
570
|
+
*/
|
|
571
|
+
export function runPath(root) {
|
|
572
|
+
return join(root, DIRNAME, 'RUN');
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The range number the RUN mark names, or null when the mark is absent or
|
|
576
|
+
* holds anything but a positive number.
|
|
577
|
+
*/
|
|
578
|
+
export function readRun(root) {
|
|
579
|
+
try {
|
|
580
|
+
const n = Number(readFileSync(runPath(root), 'utf8').trim());
|
|
581
|
+
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Rewrite (or clear) the RUN mark: a range number marks that range's run,
|
|
589
|
+
* null clears it. The turn hook calls this on every typed prompt, beside
|
|
590
|
+
* `setGrant`, and the delete lives here with the grant's.
|
|
591
|
+
*/
|
|
592
|
+
export function setRun(root, item) {
|
|
593
|
+
if (item === null) {
|
|
594
|
+
rmSync(runPath(root), { force: true });
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
writeFileSync(runPath(root), `${item}\n`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* PROMPT: the id of the last prompt the turn hook saw, typed or not.
|
|
602
|
+
*/
|
|
603
|
+
export function promptPath(root) {
|
|
604
|
+
return join(root, DIRNAME, 'PROMPT');
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* The id of the last prompt the turn hook saw, or null when none is recorded.
|
|
608
|
+
*
|
|
609
|
+
* A notice the harness folds into a running turn arrives under that turn's id,
|
|
610
|
+
* where one that starts a turn of its own arrives under a fresh one, so the
|
|
611
|
+
* hook compares a notice's id against this one to tell the two apart.
|
|
612
|
+
*/
|
|
613
|
+
export function readPrompt(root) {
|
|
614
|
+
try {
|
|
615
|
+
const id = readFileSync(promptPath(root), 'utf8').trim();
|
|
616
|
+
return id === '' ? null : id;
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Record the id of the prompt the hook just saw, or clear the record when the
|
|
624
|
+
* payload carried none, so a stale id can never make a later notice look as
|
|
625
|
+
* though it joined the running turn.
|
|
626
|
+
*/
|
|
627
|
+
export function notePrompt(root, id) {
|
|
628
|
+
if (id === null) {
|
|
629
|
+
rmSync(promptPath(root), { force: true });
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
writeFileSync(promptPath(root), `${id}\n`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
507
635
|
/**
|
|
508
636
|
* TICK: the per-build entry stamp: the TURN value recorded when work was
|
|
509
637
|
* entered (`build <n>` for a step, `start` for the plan), cleared when
|
|
@@ -561,11 +689,13 @@ export function readTurn(root) {
|
|
|
561
689
|
// ending: a decision ending closes the turn, an orientation ending appears in
|
|
562
690
|
// it, and a driver ending is recorded only so that the last ending wins.
|
|
563
691
|
const RELAY_TIERS = ['decision', 'orientation', 'driver'];
|
|
564
|
-
// Which ending printed: handoff's
|
|
565
|
-
// is what the check's reason names when the ending went missing.
|
|
692
|
+
// Which ending printed: handoff's eight forms first, then one per transition.
|
|
693
|
+
// It is what the check's reason names when the ending went missing.
|
|
566
694
|
const RELAY_RENDERS = [
|
|
567
695
|
'card',
|
|
568
696
|
'reminder',
|
|
697
|
+
'stop',
|
|
698
|
+
'stop-reminder',
|
|
569
699
|
'plan',
|
|
570
700
|
'boundary',
|
|
571
701
|
'pointer',
|
|
@@ -580,6 +710,7 @@ const RELAY_RENDERS = [
|
|
|
580
710
|
'spike-done',
|
|
581
711
|
'use',
|
|
582
712
|
'order',
|
|
713
|
+
'harvest',
|
|
583
714
|
'finish',
|
|
584
715
|
'start',
|
|
585
716
|
];
|
|
@@ -612,6 +743,18 @@ export function writeRelay(root, relay) {
|
|
|
612
743
|
// A read-only tree or a directory in the way: the ending already printed.
|
|
613
744
|
}
|
|
614
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* Drop the recorded ending, so the `Stop` check has nothing to judge until a
|
|
748
|
+
* verb prints another.
|
|
749
|
+
*
|
|
750
|
+
* The turn hook calls this when a background job's notice starts a turn of its
|
|
751
|
+
* own. The ending was judged when its own turn ended, and a notice leaves TURN
|
|
752
|
+
* where it was, so without this the notice's turn would read as the one that
|
|
753
|
+
* printed the ending and be held open for a card the human has already read.
|
|
754
|
+
*/
|
|
755
|
+
export function clearRelay(root) {
|
|
756
|
+
rmSync(relayPath(root), { force: true });
|
|
757
|
+
}
|
|
615
758
|
/**
|
|
616
759
|
* Stamp the record with the turn the check held open over it, so the check
|
|
617
760
|
* holds at most once per turn. False when the stamp could not be written, and
|
|
@@ -631,10 +774,18 @@ function formatRelay(record) {
|
|
|
631
774
|
const header = [`turn ${record.turn}`, `tier ${record.tier}`, `render ${record.render}`];
|
|
632
775
|
if (record.command !== undefined)
|
|
633
776
|
header.push(`command ${record.command}`);
|
|
777
|
+
if (record.owes !== undefined) {
|
|
778
|
+
header.push(`owes range ${record.owes.range} of ${record.owes.count} after step ${record.owes.step}`);
|
|
779
|
+
}
|
|
634
780
|
if (record.held !== undefined)
|
|
635
781
|
header.push(`held ${record.held}`);
|
|
636
782
|
return `${header.join('\n')}\n\n${record.text}`;
|
|
637
783
|
}
|
|
784
|
+
/** The `owes` header field read back, or null when it is absent or does not parse. */
|
|
785
|
+
function owedOf(field) {
|
|
786
|
+
const m = /^range (\d+) of (\d+) after step (\d+)$/.exec(field ?? '');
|
|
787
|
+
return m === null ? null : { range: Number(m[1]), count: Number(m[2]), step: Number(m[3]) };
|
|
788
|
+
}
|
|
638
789
|
/**
|
|
639
790
|
* The recorded ending, or null when RELAY is absent or its header does not
|
|
640
791
|
* parse: a record the check cannot read is no record.
|
|
@@ -662,12 +813,14 @@ export function readRelay(root) {
|
|
|
662
813
|
if (turn === null || tier === undefined || render === undefined)
|
|
663
814
|
return null;
|
|
664
815
|
const command = fields.get('command');
|
|
816
|
+
const owes = owedOf(fields.get('owes'));
|
|
665
817
|
const held = count(fields.get('held'));
|
|
666
818
|
return {
|
|
667
819
|
turn,
|
|
668
820
|
tier,
|
|
669
821
|
render,
|
|
670
822
|
...(command === undefined ? {} : { command }),
|
|
823
|
+
...(owes === null ? {} : { owes }),
|
|
671
824
|
...(held === null ? {} : { held }),
|
|
672
825
|
text: raw.slice(split + 2),
|
|
673
826
|
};
|
|
@@ -739,6 +892,11 @@ export function excludeControl(root) {
|
|
|
739
892
|
// never writes, and never commits.
|
|
740
893
|
`${DIRNAME}/TURN`,
|
|
741
894
|
`${DIRNAME}/GRANT`,
|
|
895
|
+
// The range a typed run is landing, written beside the grant.
|
|
896
|
+
`${DIRNAME}/RUN`,
|
|
897
|
+
// The id of the last prompt the turn hook saw, which tells a notice folded
|
|
898
|
+
// into the running turn from one that starts its own.
|
|
899
|
+
`${DIRNAME}/PROMPT`,
|
|
742
900
|
// The in-flight step's detail file: flat per-session control the model
|
|
743
901
|
// overwrites before every pause. checkpoint records it in the build-log's
|
|
744
902
|
// Log and then clears it, so its archive is the tracked ledger, never this
|
|
@@ -756,6 +914,9 @@ export function excludeControl(root) {
|
|
|
756
914
|
// The pause stamp is in-flight control too: what handoff last showed, so
|
|
757
915
|
// a repeat prints the reminder. It must never ride a step commit.
|
|
758
916
|
`${DIRNAME}/builds/*/PAUSE`,
|
|
917
|
+
// The stop stamp likewise: what the stop card last showed, and the turn it
|
|
918
|
+
// printed in, for the reminder and the record's latch.
|
|
919
|
+
`${DIRNAME}/builds/*/STOP`,
|
|
759
920
|
// The agent-run handoff ledger is step-scoped in-flight state, not a
|
|
760
921
|
// tracked artifact: it must never ride a step commit into the PR.
|
|
761
922
|
`${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/build.js
CHANGED
|
@@ -8,18 +8,22 @@ 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, stopFollowUp } 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";
|
|
16
|
+
import { waitingCard } from "./handoff.js";
|
|
15
17
|
/**
|
|
16
18
|
* Enter step n (or the next undone step) and write its SEAM/STEP markers.
|
|
17
19
|
*
|
|
18
|
-
* Refuses a missing session, a malformed step argument,
|
|
19
|
-
* `/plumbbob:build` skill feature, not a CLI one),
|
|
20
|
+
* Refuses a missing session, a malformed step argument, a range such as `N-M`
|
|
21
|
+
* or `20,5-9` (a `/plumbbob:build` skill feature, not a CLI one), a stop card
|
|
22
|
+
* still waiting for its reply, and a seam that fails to parse.
|
|
20
23
|
* On entry it stamps the turn ledger and the step's start time, re-renders the
|
|
21
24
|
* build-log's Current step line, and names the step when it is the last one a
|
|
22
|
-
* self-approval grant reaches
|
|
25
|
+
* self-approval grant reaches, or, under `--auto`, a call the plan's ranges
|
|
26
|
+
* reserve for the human.
|
|
23
27
|
*/
|
|
24
28
|
export function build(cwd, args) {
|
|
25
29
|
const root = findRepoRoot(cwd);
|
|
@@ -29,15 +33,15 @@ export function build(cwd, args) {
|
|
|
29
33
|
}
|
|
30
34
|
const { build: slug, rest } = resolveBuild(root, args);
|
|
31
35
|
const raw = rest.find((a) => !a.startsWith('--'));
|
|
32
|
-
// A step range like `1-3` is a `/plumbbob:build` skill affordance
|
|
33
|
-
//
|
|
36
|
+
// A step range like `1-3` or `20,5-9` is a `/plumbbob:build` skill affordance
|
|
37
|
+
// (auto-approve each step it names, then stop), not a CLI capability: the CLI
|
|
34
38
|
// records one in-flight step at a time. Name it rather than bounce off the
|
|
35
|
-
// generic usage.
|
|
36
|
-
if (raw !== undefined && /^\d
|
|
39
|
+
// generic usage, and point at the step the range starts at.
|
|
40
|
+
if (raw !== undefined && /^\d[\d,-]*$/.test(raw) && /[,-]/.test(raw)) {
|
|
37
41
|
process.stderr.write(notice({
|
|
38
42
|
fact: 'build takes one step number',
|
|
39
43
|
detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through its top and then stops`],
|
|
40
|
-
remedy: `plumbbob build ${
|
|
44
|
+
remedy: `plumbbob build ${rangeEntry(intentPath(root, slug), rest.join(' '), raw)}`,
|
|
41
45
|
}));
|
|
42
46
|
return 1;
|
|
43
47
|
}
|
|
@@ -48,6 +52,15 @@ export function build(cwd, args) {
|
|
|
48
52
|
return 1;
|
|
49
53
|
}
|
|
50
54
|
const intent = readFileSync(intentPath(root, slug), 'utf8');
|
|
55
|
+
// A stop card waiting for its reply holds every step, bare or named: the
|
|
56
|
+
// step's pause would write over the range's review in the detail file before
|
|
57
|
+
// the reply recorded it. Nothing is written, and on the plan's last range the
|
|
58
|
+
// card outranks "no undone step".
|
|
59
|
+
const waiting = waitingNotice(root, slug, intent);
|
|
60
|
+
if (waiting.length > 0) {
|
|
61
|
+
process.stderr.write(waiting);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
51
64
|
// In build order: the sequence `## Build order` declares when a refined plan
|
|
52
65
|
// has one, else the numbering, so this pick and the dashboard's agree.
|
|
53
66
|
const steps = parseOrderedSteps(intent);
|
|
@@ -100,11 +113,15 @@ export function build(cwd, args) {
|
|
|
100
113
|
: skipped > 0
|
|
101
114
|
? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
|
|
102
115
|
: [];
|
|
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.
|
|
116
|
+
// Two lines (three on the last step a grant reaches, or on a call the plan
|
|
117
|
+
// reserves), one colon each, then the seam as a plain readout beneath the
|
|
118
|
+
// notice that frames it: the paths are a list, and a list is not a one-liner.
|
|
119
|
+
// A reserved call outranks the last-step line, since the step stops at its
|
|
120
|
+
// pause rather than landing.
|
|
121
|
+
const grant = readGrant(root);
|
|
122
|
+
const reserved = grant?.kind === 'auto' ? reservedCallNotice(intent, steps, step) : '';
|
|
106
123
|
process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
|
|
107
|
-
lastGrantedNotice(
|
|
124
|
+
(reserved.length > 0 ? reserved : lastGrantedNotice(grant, steps, step)) +
|
|
108
125
|
notice({
|
|
109
126
|
fact: 'the seam is orientation, not a lock',
|
|
110
127
|
detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
|
|
@@ -112,6 +129,70 @@ export function build(cwd, args) {
|
|
|
112
129
|
`${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
|
|
113
130
|
return 0;
|
|
114
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* The refusal while a stop card waits for its reply, or '' when none waits:
|
|
134
|
+
* the card, then the reply that records it. `looks good` always does, and the
|
|
135
|
+
* remedy adds the move the card's second row names, read through the same
|
|
136
|
+
* `stopFollowUp`: the next range's run, the finish on the plan's last range,
|
|
137
|
+
* or nothing more when the next run would refuse. The remedy spends a
|
|
138
|
+
* semicolon, since the refusal register keeps its one colon for the prefix.
|
|
139
|
+
*/
|
|
140
|
+
function waitingNotice(root, slug, intent) {
|
|
141
|
+
const card = waitingCard(root, slug, intent);
|
|
142
|
+
if (card === null) {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
const follow = stopFollowUp(intent);
|
|
146
|
+
const also = follow === null
|
|
147
|
+
? ''
|
|
148
|
+
: follow === 'finish'
|
|
149
|
+
? ', and /plumbbob:finish records it, then writes the report'
|
|
150
|
+
: `, and /plumbbob:run records it, then runs range ${follow.range.item} of ${follow.count}`;
|
|
151
|
+
return notice({
|
|
152
|
+
fact: 'no step entered',
|
|
153
|
+
detail: [`range ${card.range} of ${card.count}'s stop card is waiting for its reply`],
|
|
154
|
+
remedy: `reply to the card; looks good records it${also}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The step a range typed at the CLI starts at: its first undone step in build
|
|
159
|
+
* order when the plan reads, else its first number written.
|
|
160
|
+
*
|
|
161
|
+
* Best-effort, since it only fills the refusal's remedy: a plan that cannot be
|
|
162
|
+
* read, or a range that does not parse, still points at a number the human typed.
|
|
163
|
+
*/
|
|
164
|
+
function rangeEntry(intent, args, raw) {
|
|
165
|
+
const list = readBuildArgs(args).steps;
|
|
166
|
+
try {
|
|
167
|
+
const start = list === null ? null : rangeStart(parseOrderedSteps(readFileSync(intent, 'utf8')), list);
|
|
168
|
+
if (start !== null)
|
|
169
|
+
return String(start);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// No plan to order by: fall back to the number typed first.
|
|
173
|
+
}
|
|
174
|
+
return /\d+/.exec(raw)?.[0] ?? '1';
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The line saying a step ends at its pause under `--auto`, because the plan's
|
|
178
|
+
* build ranges name it as a bare range: a call the plan reserves for the
|
|
179
|
+
* human. '' for any other step.
|
|
180
|
+
*
|
|
181
|
+
* The halt is this line's and the skill's, never the latch's: the ranges are
|
|
182
|
+
* advice, so `--auto` still grants the checkpoint, and saying so as the step
|
|
183
|
+
* is entered is what puts the stop in front of the model before it lands one.
|
|
184
|
+
*/
|
|
185
|
+
function reservedCallNotice(intent, steps, step) {
|
|
186
|
+
const { count, holding } = rangeStanding(parseBuildRanges(intent), steps, step);
|
|
187
|
+
if (holding === null || !holding.bare) {
|
|
188
|
+
return '';
|
|
189
|
+
}
|
|
190
|
+
return notice({
|
|
191
|
+
fact: `step ${step} ends at its pause, where the plan reserves the call for the human`,
|
|
192
|
+
detail: [`range ${holding.item} of ${count}`],
|
|
193
|
+
remedy: 'hold it there for their approval, --auto or not',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
115
196
|
/**
|
|
116
197
|
* The line naming the last step a self-approval grant reaches this turn, or ''
|
|
117
198
|
* for any other step and when no grant is live.
|
|
@@ -123,13 +204,26 @@ export function build(cwd, args) {
|
|
|
123
204
|
* reaches its top, or the last undone step under its top when the plan ends
|
|
124
205
|
* first, or when the build order puts a number past the top ahead of the rest
|
|
125
206
|
* (a range names plan numbers, and the skill halts before one past its top);
|
|
126
|
-
* `--auto` reaches the plan's last undone step.
|
|
207
|
+
* `--auto` reaches the plan's last undone step. A range that lists its steps
|
|
208
|
+
* walks them in build order, so its top is the last undone step it names, and
|
|
209
|
+
* it reaches no further than a step it does not name.
|
|
127
210
|
*/
|
|
128
211
|
function lastGrantedNotice(grant, steps, step) {
|
|
129
212
|
if (grant === null) {
|
|
130
213
|
return '';
|
|
131
214
|
}
|
|
132
215
|
const remedy = 'land it on green, then stop at the boundary';
|
|
216
|
+
if (grant.kind === 'steps') {
|
|
217
|
+
if (!hasStep(grant.steps, step))
|
|
218
|
+
return '';
|
|
219
|
+
const rest = steps.slice(steps.findIndex((s) => s.n === step) + 1).filter((s) => !s.done && s.n !== step);
|
|
220
|
+
const after = rest[0];
|
|
221
|
+
if (after !== undefined && hasStep(grant.steps, after.n))
|
|
222
|
+
return '';
|
|
223
|
+
return rest.some((s) => hasStep(grant.steps, s.n))
|
|
224
|
+
? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
|
|
225
|
+
: notice({ fact: `step ${step} is the top of the range you granted`, remedy });
|
|
226
|
+
}
|
|
133
227
|
if (grant.kind === 'range') {
|
|
134
228
|
if (step === grant.ceiling) {
|
|
135
229
|
return notice({ fact: `step ${step} is the top of the range you granted`, remedy });
|