plumbbob 0.13.0 → 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 +8 -6
- package/dist/cli-core.js +8 -3
- package/dist/lib/latch.js +23 -5
- package/dist/lib/notice.js +3 -2
- package/dist/lib/orient.js +307 -33
- package/dist/lib/sidecar.js +53 -0
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/build.js +73 -14
- package/dist/verbs/checkpoint.js +1 -0
- package/dist/verbs/handoff.js +86 -26
- package/dist/verbs/order.js +15 -11
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/turn.js +83 -52
- package/package.json +1 -1
- package/skills/build/SKILL.md +72 -33
- package/skills/harvest/SKILL.md +10 -1
- package/skills/order/SKILL.md +20 -10
- package/skills/plan/SKILL.md +51 -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 +1 -1
- package/templates/intent.md +23 -5
|
@@ -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,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
|
@@ -129,6 +129,7 @@ export async function checkpoint(cwd, args) {
|
|
|
129
129
|
lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
|
|
130
130
|
verdict: landed.verdict,
|
|
131
131
|
advisories: [drift, flipNotice],
|
|
132
|
+
stop: landed.stop,
|
|
132
133
|
pointer: landed.pointer,
|
|
133
134
|
}),
|
|
134
135
|
});
|
package/dist/verbs/handoff.js
CHANGED
|
@@ -41,10 +41,10 @@ import { createHash } from 'node:crypto';
|
|
|
41
41
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
42
42
|
import { join, relative } from 'node:path';
|
|
43
43
|
import { PLAN_COMMIT_MARKER, commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
|
|
44
|
-
import { blocks, printEnding } from "../lib/notice.js";
|
|
44
|
+
import { blocks, printEnding, transition } from "../lib/notice.js";
|
|
45
45
|
import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
|
|
46
46
|
import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, pausePath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
|
|
47
|
-
import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
|
|
47
|
+
import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeStanding, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
|
|
48
48
|
// The inline-diff threshold (docs/presentation.md): at 20 changed lines or
|
|
49
49
|
// fewer the patch rides the turn; at 21 it stays in the working tree behind
|
|
50
50
|
// the counted diff row.
|
|
@@ -108,7 +108,7 @@ export function handoff(cwd, args = []) {
|
|
|
108
108
|
const detail = settleDetail(root, rawDetail);
|
|
109
109
|
const plan = [
|
|
110
110
|
SEAM_RULE,
|
|
111
|
-
nextUpBlock(
|
|
111
|
+
nextUpBlock(intent, buildLog, first, null, where),
|
|
112
112
|
planCallBlock(first),
|
|
113
113
|
];
|
|
114
114
|
const command = reprintCommand('--plan', undefined, named);
|
|
@@ -137,7 +137,7 @@ export function handoff(cwd, args = []) {
|
|
|
137
137
|
if (current === null) {
|
|
138
138
|
// Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
|
|
139
139
|
return emit(root, { tier: 'orientation', render: 'pointer' }, [
|
|
140
|
-
nextUpBlock(
|
|
140
|
+
nextUpBlock(intent, buildLog, nextUp, current, where),
|
|
141
141
|
]);
|
|
142
142
|
}
|
|
143
143
|
// A step is pending, and its hand-off is the pause, while it is in flight.
|
|
@@ -147,11 +147,12 @@ export function handoff(cwd, args = []) {
|
|
|
147
147
|
// which pause to render.
|
|
148
148
|
const pending = inFlight !== null || (explicit !== undefined && steps.some((s) => s.n === current && !s.done));
|
|
149
149
|
if (!pending) {
|
|
150
|
-
// The boundary: the orientation-tier ending, the state word
|
|
151
|
-
//
|
|
152
|
-
// checkpoint printed for itself,
|
|
150
|
+
// The boundary: the orientation-tier ending, the state word, the Stop part
|
|
151
|
+
// where a range just landed, and the forward pointer, no decision pending
|
|
152
|
+
// and so no Your Call. The same parts the checkpoint printed for itself,
|
|
153
|
+
// from the same call.
|
|
153
154
|
const landed = boundaryEnding(root, slug, current);
|
|
154
|
-
return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.pointer]);
|
|
155
|
+
return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.stop, landed.pointer]);
|
|
155
156
|
}
|
|
156
157
|
// The pause: the whole turn as one contiguous block, each part a labeled line
|
|
157
158
|
// with one blank line between, and the only fence the readout's own (plus the
|
|
@@ -197,7 +198,7 @@ export function handoff(cwd, args = []) {
|
|
|
197
198
|
if (inline) {
|
|
198
199
|
parts.push(fence('diff', patch.split('\n')).join('\n'));
|
|
199
200
|
}
|
|
200
|
-
parts.push(verdict, nextUpBlock(
|
|
201
|
+
parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen));
|
|
201
202
|
const command = reprintCommand('--full', explicit, named);
|
|
202
203
|
return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
|
|
203
204
|
}
|
|
@@ -267,25 +268,52 @@ function writePause(root, slug, stamp) {
|
|
|
267
268
|
}
|
|
268
269
|
}
|
|
269
270
|
/**
|
|
270
|
-
* The
|
|
271
|
-
* everything measurable right now,
|
|
271
|
+
* The CLI-rendered parts of a boundary ending: the Verdict folded from
|
|
272
|
+
* everything measurable right now, the Stop part when the landing finished a
|
|
273
|
+
* range, and the pointer at the step to come.
|
|
272
274
|
*
|
|
273
275
|
* `step` is the step that just landed, or null at a plan commit, where nothing
|
|
274
|
-
* was measured and the Verdict
|
|
275
|
-
* its own lead line, so a boundary reads identically
|
|
276
|
-
* emitted it or a later `handoff` did; the paths a stray
|
|
277
|
-
* one part that does not survive, and the commit holds
|
|
276
|
+
* was measured and the Verdict and the Stop part both vanish. A transition verb
|
|
277
|
+
* prints these beneath its own lead line, so a boundary reads identically
|
|
278
|
+
* whether `checkpoint` emitted it or a later `handoff` did; the paths a stray
|
|
279
|
+
* advisory named are the one part that does not survive, and the commit holds
|
|
280
|
+
* those.
|
|
278
281
|
*/
|
|
279
282
|
export function boundaryEnding(root, slug, step) {
|
|
280
283
|
const intent = readOr(intentPath(root, slug));
|
|
281
284
|
const steps = parseOrderedSteps(intent);
|
|
282
285
|
const nextUp = steps.find((s) => !s.done && s.n !== step);
|
|
283
|
-
const
|
|
286
|
+
const where = relative(root, intentPath(root, slug));
|
|
284
287
|
return {
|
|
285
288
|
verdict: step === null ? null : landedVerdict(root, slug, intent, step),
|
|
286
|
-
|
|
289
|
+
stop: stopLine(intent, steps, step, where),
|
|
290
|
+
pointer: nextUpBlock(intent, readOr(buildLogPath(root, slug)), nextUp, step, where),
|
|
287
291
|
};
|
|
288
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* The Stop part: why the build stops at this boundary, when the step that
|
|
295
|
+
* landed was the last undone step of a range that lands its steps. One labeled
|
|
296
|
+
* line, the range over the count, a plain hyphen, the stop as the plan wrote
|
|
297
|
+
* it, and a bracket pointing at the item, where its note can be read.
|
|
298
|
+
*
|
|
299
|
+
* Null for every other boundary: a landing that leaves the range open, a bare
|
|
300
|
+
* range (its stop was its own pause), a plan without ranges, and the plan
|
|
301
|
+
* commit, where nothing landed.
|
|
302
|
+
*/
|
|
303
|
+
function stopLine(intent, steps, step, where) {
|
|
304
|
+
if (step === null) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const { count, completed } = rangeStanding(parseBuildRanges(intent), steps, step);
|
|
308
|
+
if (completed === null || completed.bare) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
return transition({
|
|
312
|
+
label: 'Stop',
|
|
313
|
+
fact: `Range ${completed.item} of ${count} landed - ${completed.stop}`,
|
|
314
|
+
detail: [`details: \`${where}:${completed.line}\``],
|
|
315
|
+
});
|
|
316
|
+
}
|
|
289
317
|
/**
|
|
290
318
|
* The step's record for the build-log's Log: the pause as the human approved
|
|
291
319
|
* it, with the full story behind each highlight beneath. The Summary and its
|
|
@@ -658,10 +686,11 @@ function spikeNextUpLine(inFlight) {
|
|
|
658
686
|
// count. The line is a reminder; the dashboard holds the whole sequence.
|
|
659
687
|
const ORDER_CAP = 5;
|
|
660
688
|
/**
|
|
661
|
-
* The line beneath the forward pointer: the
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
689
|
+
* The line beneath the forward pointer: the range segment, when the plan
|
|
690
|
+
* declares build ranges; the build order from the step the pointer names, when
|
|
691
|
+
* `## Build order` re-sequences the plan away from its numbering; and the park
|
|
692
|
+
* count, when anything has been parked. '' when none says anything, so a plan
|
|
693
|
+
* with no ranges, nobody re-sequenced, and an empty park list renders the
|
|
665
694
|
* pointer alone, as before.
|
|
666
695
|
*
|
|
667
696
|
* Indented two spaces, because an indented line continues the part above it
|
|
@@ -670,9 +699,10 @@ const ORDER_CAP = 5;
|
|
|
670
699
|
* `Nothing planned`: an unharvested list at the end of the plan is the one
|
|
671
700
|
* reminder that matters there.
|
|
672
701
|
*/
|
|
673
|
-
function nextUpNote(intent, buildLog, exclude) {
|
|
702
|
+
function nextUpNote(intent, buildLog, exclude, range) {
|
|
674
703
|
const parks = parseParkTotals(buildLog);
|
|
675
704
|
const segments = [
|
|
705
|
+
range,
|
|
676
706
|
buildOrderPhrase(remainingOrder(parseSteps(intent), parseBuildOrder(intent), exclude), ORDER_CAP),
|
|
677
707
|
parks.total > 0 ? parkedPhrase(parks) : '',
|
|
678
708
|
].filter((s) => s.length > 0);
|
|
@@ -681,11 +711,41 @@ function nextUpNote(intent, buildLog, exclude) {
|
|
|
681
711
|
/**
|
|
682
712
|
* The forward pointer with its note beneath, as one part: `blocks` keeps a
|
|
683
713
|
* part's own newlines, so the note rides flush under the line it qualifies.
|
|
714
|
+
*
|
|
715
|
+
* `exclude` is the step the turn is about: the step in flight at a pause, the
|
|
716
|
+
* step just landed at a boundary, null at the plan pause and the plan commit.
|
|
717
|
+
* While the note suggests a range, the bracket bolds the range's model, since
|
|
718
|
+
* the run is the range and that is the `/model` the human picks before typing
|
|
719
|
+
* it.
|
|
684
720
|
*/
|
|
685
|
-
function nextUpBlock(nextUp,
|
|
686
|
-
const
|
|
721
|
+
function nextUpBlock(intent, buildLog, nextUp, exclude, where) {
|
|
722
|
+
const steps = parseOrderedSteps(intent);
|
|
723
|
+
const range = rangeSegment(intent, steps, nextUp, exclude);
|
|
724
|
+
const line = nextUpLine(nextUp, steps.length, where, range.model ?? nextUp?.model ?? null);
|
|
725
|
+
const note = nextUpNote(intent, buildLog, exclude, range.segment);
|
|
687
726
|
return note.length === 0 ? line : `${line}\n${note}`;
|
|
688
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* The range segment that leads the note, and the model the pointer's bracket
|
|
730
|
+
* bolds while it suggests a range: `range 3 of 5: /plumbbob:build 6-9` with
|
|
731
|
+
* the range's model, the re-cut words for a flagged range, the words for a step
|
|
732
|
+
* no range names, or '' for a plan without ranges.
|
|
733
|
+
*
|
|
734
|
+
* The step the turn is about reads as landed, so a step pause names what is
|
|
735
|
+
* left of the range once the step in flight lands. A flagged range, and a
|
|
736
|
+
* range with no `- model:` line, leave the step's model in the bracket.
|
|
737
|
+
*/
|
|
738
|
+
function rangeSegment(intent, steps, nextUp, exclude) {
|
|
739
|
+
const landed = steps.map((s) => (s.n === exclude ? { ...s, done: true } : s));
|
|
740
|
+
const { count, holding, command, flag } = rangeStanding(parseBuildRanges(intent), landed, nextUp?.n ?? null);
|
|
741
|
+
if (holding !== null && command !== null) {
|
|
742
|
+
return { segment: `range ${holding.item} of ${count}: ${command}`, model: holding.model };
|
|
743
|
+
}
|
|
744
|
+
if (flag === null) {
|
|
745
|
+
return { segment: '', model: null };
|
|
746
|
+
}
|
|
747
|
+
return { segment: holding === null ? flag : `range ${holding.item} of ${count} needs a re-cut (${flag})`, model: null };
|
|
748
|
+
}
|
|
689
749
|
/**
|
|
690
750
|
* The forward pointer: the next undone step with the progress count, its
|
|
691
751
|
* title, and a closing bracket carrying its advisory `- model:` recommendation
|
|
@@ -698,12 +758,12 @@ function nextUpBlock(nextUp, total, where, note) {
|
|
|
698
758
|
* reads as a path in a PR diff. The line alone is the driver tier's pointer;
|
|
699
759
|
* every other tier rides `nextUpNote` beneath it, through `nextUpBlock`.
|
|
700
760
|
*/
|
|
701
|
-
function nextUpLine(nextUp, total, where) {
|
|
761
|
+
function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
|
|
702
762
|
if (nextUp === undefined) {
|
|
703
763
|
return '**Next Up**: Nothing planned - /plumbbob:step or /plumbbob:finish';
|
|
704
764
|
}
|
|
705
765
|
const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
|
|
706
|
-
const token = modelToken(
|
|
766
|
+
const token = modelToken(model);
|
|
707
767
|
const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
|
|
708
768
|
bracket.push(`details: \`${where}:${nextUp.line}\``);
|
|
709
769
|
return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
|
package/dist/verbs/order.js
CHANGED
|
@@ -14,16 +14,18 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
14
14
|
import { findRepoRoot } from "../lib/git.js";
|
|
15
15
|
import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
|
|
16
16
|
import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
|
|
17
|
+
import { expandStepList, parseStepList } from "../lib/steplist.js";
|
|
17
18
|
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
18
19
|
import { driverPointer } from "./handoff.js";
|
|
19
20
|
/**
|
|
20
|
-
* Set the build order to the given step numbers, the rest of the
|
|
21
|
-
* following in document order, or clear it with `--reset`.
|
|
21
|
+
* Set the build order to the given step numbers and spans, the rest of the
|
|
22
|
+
* undone steps following in document order, or clear it with `--reset`.
|
|
22
23
|
*
|
|
23
|
-
* The line written is the whole remaining sequence,
|
|
24
|
-
* intent.md sees every undone step and not only the ones
|
|
25
|
-
* Refuses (exit 1) with no session, no numbers, a token that is
|
|
26
|
-
* a number the plan lacks, a repeated number, or a
|
|
24
|
+
* The line written is the whole remaining sequence, each span spelled out, so a
|
|
25
|
+
* cold reader of intent.md sees every undone step and not only the ones
|
|
26
|
+
* re-sequenced. Refuses (exit 1) with no session, no numbers, a token that is
|
|
27
|
+
* neither a number nor a span, a number the plan lacks, a repeated number, or a
|
|
28
|
+
* step already checkpointed.
|
|
27
29
|
*/
|
|
28
30
|
export function order(cwd, args) {
|
|
29
31
|
const root = findRepoRoot(cwd);
|
|
@@ -45,8 +47,9 @@ export function order(cwd, args) {
|
|
|
45
47
|
});
|
|
46
48
|
return 0;
|
|
47
49
|
}
|
|
48
|
-
// Numbers arrive space- or comma-separated
|
|
49
|
-
//
|
|
50
|
+
// Numbers and spans arrive space- or comma-separated, with or without a space
|
|
51
|
+
// after a comma: `order 7 8 5`, `order 7,8,5`, and `order 1, 6, 2-4, 7, 5`
|
|
52
|
+
// all read, and a span names its steps in place, since the line is a sequence.
|
|
50
53
|
const tokens = args
|
|
51
54
|
.filter((a) => !a.startsWith('--'))
|
|
52
55
|
.flatMap((a) => a.split(','))
|
|
@@ -60,14 +63,15 @@ export function order(cwd, args) {
|
|
|
60
63
|
}));
|
|
61
64
|
return 1;
|
|
62
65
|
}
|
|
63
|
-
const
|
|
66
|
+
const lists = tokens.map(parseStepList);
|
|
67
|
+
const stray = tokens.find((_, i) => lists[i] === null);
|
|
64
68
|
if (stray !== undefined) {
|
|
65
|
-
process.stderr.write(notice({ fact: `\`${stray}\` is not a step number`, remedy: 'plumbbob order 7
|
|
69
|
+
process.stderr.write(notice({ fact: `\`${stray}\` is not a step number or span`, remedy: 'plumbbob order 1, 6, 2-4, 7, 5' }));
|
|
66
70
|
return 1;
|
|
67
71
|
}
|
|
68
|
-
const given = tokens.map(Number);
|
|
69
72
|
const steps = parseSteps(intent);
|
|
70
73
|
const planned = steps.map((s) => s.n);
|
|
74
|
+
const given = expandStepList(lists.flatMap((list) => list ?? []), (n) => planned.includes(n));
|
|
71
75
|
const unknown = given.find((n) => !planned.includes(n));
|
|
72
76
|
if (unknown !== undefined) {
|
|
73
77
|
process.stderr.write(notice({
|
package/dist/verbs/recover.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// deleting them is a gamble the human should take by hand.
|
|
19
19
|
import { existsSync, readFileSync } from 'node:fs';
|
|
20
20
|
import { findRepoRoot } from "../lib/git.js";
|
|
21
|
-
import { parseBuildOrder, parseSteps } from "../lib/orient.js";
|
|
21
|
+
import { parseBuildOrder, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding } from "../lib/orient.js";
|
|
22
22
|
import { activeBuild, checkpointsPath, clearHandoff, clearTick, grantPath, handoffPath, hasSession, intentPath, inSpike, listBuilds, readTurn, setActiveBuild, setGrant, stepPath, tickPath, } from "../lib/sidecar.js";
|
|
23
23
|
import { spikeBranches, spikeWorktrees } from "./spike.js";
|
|
24
24
|
/**
|
|
@@ -94,6 +94,7 @@ function sessionFindings(root) {
|
|
|
94
94
|
const stepInFlight = existsSync(stepPath(root, slug));
|
|
95
95
|
findings.push(...phaseFindings(root, slug, stepInFlight));
|
|
96
96
|
findings.push(...orderFindings(root, slug));
|
|
97
|
+
findings.push(...rangeFindings(root, slug));
|
|
97
98
|
findings.push(...leftoverFindings(root, slug, stepInFlight));
|
|
98
99
|
return findings;
|
|
99
100
|
}
|
|
@@ -126,6 +127,35 @@ function orderFindings(root, slug) {
|
|
|
126
127
|
},
|
|
127
128
|
];
|
|
128
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* A build range naming a step the plan does not hold.
|
|
132
|
+
*
|
|
133
|
+
* The section under `## Build ranges` is hand-editable, and a refine can drop
|
|
134
|
+
* a step a range names; the card then suggests no command for that range, and
|
|
135
|
+
* the dashboard says why. Reported beside the build order's own finding, never
|
|
136
|
+
* repaired: where the stop belongs now is the human's call. Silent while the
|
|
137
|
+
* plan declares no ranges.
|
|
138
|
+
*/
|
|
139
|
+
function rangeFindings(root, slug) {
|
|
140
|
+
const intent = readOr(intentPath(root, slug));
|
|
141
|
+
const plan = parseBuildRanges(intent);
|
|
142
|
+
if (plan.ranges.length === 0) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
const missing = rangeStanding(plan, parseOrderedSteps(intent), null).drift.filter((d) => d.kind === 'missing');
|
|
146
|
+
if (missing.length === 0) {
|
|
147
|
+
const count = `${plan.ranges.length} range${plan.ranges.length === 1 ? '' : 's'}`;
|
|
148
|
+
return [{ name: 'build ranges', ok: true, detail: `every step they name is planned (${count})` }];
|
|
149
|
+
}
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
name: 'build ranges',
|
|
153
|
+
ok: false,
|
|
154
|
+
detail: missing.map((d) => `range ${d.item} names a step the plan lacks: ${d.words}`).join('; '),
|
|
155
|
+
hint: 're-cut the range under ## Build ranges by hand, or with /plumbbob:refine',
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
}
|
|
129
159
|
/**
|
|
130
160
|
* The cursor names a build that is not there.
|
|
131
161
|
*
|