plumbbob 0.12.0 → 0.13.0
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 +15 -10
- package/dist/cli-core.js +25 -3
- package/dist/lib/git.js +20 -8
- package/dist/lib/notice.js +15 -1
- package/dist/lib/orient.js +266 -19
- package/dist/lib/sidecar.js +149 -3
- package/dist/verbs/abandon.js +19 -14
- package/dist/verbs/build.js +45 -22
- package/dist/verbs/checkpoint.js +34 -25
- package/dist/verbs/doctor.js +23 -4
- package/dist/verbs/finish.js +21 -17
- package/dist/verbs/handoff.js +235 -65
- package/dist/verbs/order.js +110 -0
- 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 +14 -7
- package/dist/verbs/use.js +21 -17
- package/hooks/hooks.json +7 -0
- package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
- package/node_modules/checkride/CHANGELOG.md +117 -0
- package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
- package/node_modules/checkride/dist/adapters.js +14 -10
- package/node_modules/checkride/dist/adapters.js.map +1 -1
- package/node_modules/checkride/dist/baseline/fallow.d.ts +4 -3
- package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -1
- package/node_modules/checkride/dist/baseline/fallow.js +15 -8
- package/node_modules/checkride/dist/baseline/fallow.js.map +1 -1
- package/node_modules/checkride/dist/gate.d.ts.map +1 -1
- package/node_modules/checkride/dist/gate.js +62 -44
- package/node_modules/checkride/dist/gate.js.map +1 -1
- package/node_modules/checkride/dist/init.js +3 -3
- package/node_modules/checkride/dist/init.js.map +1 -1
- package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
- package/node_modules/checkride/dist/orchestrator.js +53 -8
- package/node_modules/checkride/dist/orchestrator.js.map +1 -1
- package/node_modules/checkride/dist/pack.js +3 -3
- package/node_modules/checkride/dist/security.d.ts +4 -2
- package/node_modules/checkride/dist/security.d.ts.map +1 -1
- package/node_modules/checkride/dist/security.js +42 -8
- package/node_modules/checkride/dist/security.js.map +1 -1
- package/node_modules/checkride/dist/triage/render.js +1 -1
- package/node_modules/checkride/dist/triage/render.js.map +1 -1
- package/node_modules/checkride/package.json +10 -10
- package/node_modules/checkride/skills/check/SKILL.md +4 -3
- package/node_modules/checkride/templates/shared/cspell.json +3 -24
- package/package.json +3 -3
- package/skills/build/SKILL.md +47 -20
- package/skills/harvest/SKILL.md +4 -1
- package/skills/order/SKILL.md +50 -0
- package/skills/park/SKILL.md +7 -0
- package/skills/plan/SKILL.md +4 -1
- package/skills/refine/SKILL.md +6 -2
- package/skills/step/SKILL.md +8 -3
- package/skills/verify/SKILL.md +42 -22
- package/templates/intent.md +9 -0
package/dist/verbs/handoff.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
// `plumbbob handoff`: emit the CLI-owned ending of a turn as one contiguous
|
|
2
|
-
// block (docs/presentation.md).
|
|
2
|
+
// block (docs/presentation.md). Changes no state but three: at a pause it
|
|
3
|
+
// leaves the detail file with a blank line around each heading, at a step pause
|
|
4
|
+
// it stamps what the card showed in `PAUSE`, and every render it prints is
|
|
5
|
+
// recorded in `RELAY` for the check at the turn's end. Every part outside
|
|
3
6
|
// the readout fence is a bold label, a colon, and text that wraps, one blank
|
|
4
7
|
// line between blocks and no fence but the readout's own. It derives the moment
|
|
5
|
-
// from the session: a step in flight ⇒ the decision-tier ending (the
|
|
6
|
-
// Readout and its fence, the inline diff when
|
|
7
|
-
// the Verdict, Next Up, Your Call, and the
|
|
8
|
+
// from the session: a step in flight ⇒ the decision-tier ending (the Now line
|
|
9
|
+
// naming that step, the labeled Readout and its fence, the inline diff when
|
|
10
|
+
// the change is 20 lines or fewer, the Verdict, Next Up, Your Call, and the
|
|
11
|
+
// Recommendation last); a landed step
|
|
8
12
|
// with none in flight ⇒ the orientation-tier ending (Verdict and Next Up); a
|
|
9
13
|
// fresh session with nothing yet measured ⇒ the forward pointer alone.
|
|
10
14
|
//
|
|
15
|
+
// A step pause renders its card once (D87 (pause-once)). While the stamp still
|
|
16
|
+
// matches, a later render is the one-line reminder, the driver pointer back at
|
|
17
|
+
// the step; the card comes back when the pause changes or on `--full`.
|
|
18
|
+
//
|
|
11
19
|
// The whole decision turn is rendered here, its opening block included: the
|
|
12
20
|
// Summary lead and the numbered highlights come out of `.plumbbob/detail.md`
|
|
13
21
|
// as the markdown the model wrote, with the `(details: …)` bracket appended so
|
|
@@ -29,22 +37,18 @@
|
|
|
29
37
|
// the step names closing the spike, a step exit (a revert, an abandon, a spike
|
|
30
38
|
// closed at the boundary) finds nothing in flight and points forward, with no
|
|
31
39
|
// Verdict either way, since nothing landed.
|
|
32
|
-
import {
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
33
42
|
import { join, relative } from 'node:path';
|
|
34
|
-
import { commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
|
|
35
|
-
import { blocks } from "../lib/notice.js";
|
|
43
|
+
import { PLAN_COMMIT_MARKER, commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
|
|
44
|
+
import { blocks, printEnding } from "../lib/notice.js";
|
|
36
45
|
import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
|
|
37
|
-
import { checkpointsPath, detailPath, hasSession, inSpike, intentPath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
|
|
38
|
-
import { countDiff, diffRowValue, foldVerdict, lastLedgerSha, parseConstraintCount, parseLastCheckpoint, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
|
|
46
|
+
import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, pausePath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
|
|
47
|
+
import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
|
|
39
48
|
// The inline-diff threshold (docs/presentation.md): at 20 changed lines or
|
|
40
49
|
// fewer the patch rides the turn; at 21 it stays in the working tree behind
|
|
41
50
|
// the counted diff row.
|
|
42
51
|
const INLINE_DIFF_MAX = 20;
|
|
43
|
-
// plumbbob's own plan commits carry this marker on a line of their body. A
|
|
44
|
-
// `chore(plan)` harvest lands between nearly every step, so counting them as
|
|
45
|
-
// out-of-band would trip the advisory rung on routine housekeeping and stop
|
|
46
|
-
// meaning anything within three steps.
|
|
47
|
-
const PLAN_COMMIT_MARKER = '^plumbbob plan$';
|
|
48
52
|
// The seam rule, and the one tier that still has a seam to mark: at the plan
|
|
49
53
|
// pause the model presents the framed plan above the relay, so the rule keeps
|
|
50
54
|
// a label from reading as the tail of what precedes it, and a run of blank
|
|
@@ -58,9 +62,14 @@ const SEAM_RULE = '\n---';
|
|
|
58
62
|
*
|
|
59
63
|
* Requires an active session (the STATE sentinel under `.plumbbob/`). The step
|
|
60
64
|
* tiers are derived, not passed: a step in flight yields the full
|
|
61
|
-
* decision-tier ending (the Readout and its fence, the inline
|
|
62
|
-
* enough, the Verdict, Next Up, Your Call, and the
|
|
63
|
-
*
|
|
65
|
+
* decision-tier ending (the Now line, the Readout and its fence, the inline
|
|
66
|
+
* diff when small enough, the Verdict, Next Up, Your Call, and the
|
|
67
|
+
* Recommendation), and so does
|
|
68
|
+
* a named step that has not landed when nothing is in flight (a hand-built diff
|
|
69
|
+
* under verify). That card renders once: a later render of the same pause, with
|
|
70
|
+
* nothing in it changed, is the one-line reminder, and `--full` prints the card
|
|
71
|
+
* whatever the stamp says. A landed step yields the orientation-tier ending
|
|
72
|
+
* (Verdict and Next Up, no Your Call),
|
|
64
73
|
* and a fresh session with nothing to report yields only
|
|
65
74
|
* the forward pointer. The two endings no session state can distinguish are
|
|
66
75
|
* named by a flag: `--plan` is the plan pause (a decision turn about the plan,
|
|
@@ -78,10 +87,16 @@ export function handoff(cwd, args = []) {
|
|
|
78
87
|
process.stderr.write('plumbbob: handoff: --plan and --driver name different tiers; pass one.\n');
|
|
79
88
|
return 1;
|
|
80
89
|
}
|
|
90
|
+
// A build named by flag rides any command that reprints this ending, since
|
|
91
|
+
// the cursor may point elsewhere.
|
|
92
|
+
const named = args.includes('--build') ? slug : null;
|
|
81
93
|
const intent = readOr(intentPath(root, slug));
|
|
82
|
-
|
|
94
|
+
// In build order, so the pointer names the step a bare `plumbbob build`
|
|
95
|
+
// would enter; the build-log feeds the park count beneath it.
|
|
96
|
+
const steps = parseOrderedSteps(intent);
|
|
97
|
+
const buildLog = readOr(buildLogPath(root, slug));
|
|
83
98
|
const inFlight = readMarker(stepPath(root, slug));
|
|
84
|
-
const
|
|
99
|
+
const rawDetail = readOr(detailPath(root));
|
|
85
100
|
// Where a pointer sends the reader to read the step itself: the intent file
|
|
86
101
|
// as the human would type it, relative to the repo root.
|
|
87
102
|
const where = relative(root, intentPath(root, slug));
|
|
@@ -90,8 +105,14 @@ export function handoff(cwd, args = []) {
|
|
|
90
105
|
// renders; the pointer and the moves both aim at the first undone step. A
|
|
91
106
|
// decision turn still ends on the model's recommendation when it wrote one.
|
|
92
107
|
const first = steps.find((s) => !s.done);
|
|
93
|
-
const
|
|
94
|
-
|
|
108
|
+
const detail = settleDetail(root, rawDetail);
|
|
109
|
+
const plan = [
|
|
110
|
+
SEAM_RULE,
|
|
111
|
+
nextUpBlock(first, steps.length, where, nextUpNote(intent, buildLog, null)),
|
|
112
|
+
planCallBlock(first),
|
|
113
|
+
];
|
|
114
|
+
const command = reprintCommand('--plan', undefined, named);
|
|
115
|
+
return emit(root, { tier: 'decision', render: 'plan', command }, withRecommendation(plan, parseRecommendation(detail)));
|
|
95
116
|
}
|
|
96
117
|
const explicit = rest.find((a) => /^\d+$/.test(a));
|
|
97
118
|
const lastDone = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
|
|
@@ -111,23 +132,33 @@ export function handoff(cwd, args = []) {
|
|
|
111
132
|
// (a step exit: a revert, an abandon, a spike closed at the boundary) the
|
|
112
133
|
// ordinary forward pointer stands, and no Verdict rides above it: nothing
|
|
113
134
|
// landed to measure.
|
|
114
|
-
return emit([driverPointer(root, slug)]);
|
|
135
|
+
return emit(root, { tier: 'driver', render: 'driver' }, [driverPointer(root, slug)]);
|
|
115
136
|
}
|
|
116
137
|
if (current === null) {
|
|
117
138
|
// Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
|
|
118
|
-
return emit(
|
|
139
|
+
return emit(root, { tier: 'orientation', render: 'pointer' }, [
|
|
140
|
+
nextUpBlock(nextUp, steps.length, where, nextUpNote(intent, buildLog, current)),
|
|
141
|
+
]);
|
|
119
142
|
}
|
|
120
|
-
|
|
143
|
+
// A step is pending, and its hand-off is the pause, while it is in flight.
|
|
144
|
+
// With nothing in flight, a step the turn names that has not landed is
|
|
145
|
+
// pending too: a diff built outside `/plumbbob:build` reaches
|
|
146
|
+
// `/plumbbob:verify` with no STEP marker, and the number is how the skill says
|
|
147
|
+
// which pause to render.
|
|
148
|
+
const pending = inFlight !== null || (explicit !== undefined && steps.some((s) => s.n === current && !s.done));
|
|
149
|
+
if (!pending) {
|
|
121
150
|
// The boundary: the orientation-tier ending, the state word and the forward
|
|
122
151
|
// pointer, no decision pending and so no Your Call. The same two parts the
|
|
123
152
|
// checkpoint printed for itself, from the same call.
|
|
124
153
|
const landed = boundaryEnding(root, slug, current);
|
|
125
|
-
return emit([landed.verdict, landed.pointer]);
|
|
154
|
+
return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.pointer]);
|
|
126
155
|
}
|
|
127
156
|
// The pause: the whole turn as one contiguous block, each part a labeled line
|
|
128
157
|
// with one blank line between, and the only fence the readout's own (plus the
|
|
129
|
-
// inline diff when the change is small enough). It opens on the
|
|
130
|
-
// model wrote into the detail file, so there
|
|
158
|
+
// inline diff when the change is small enough). It opens on the step in
|
|
159
|
+
// flight and then the Summary the model wrote into the detail file, so there
|
|
160
|
+
// is no seam left to rule off.
|
|
161
|
+
const detail = settleDetail(root, rawDetail);
|
|
131
162
|
const { rows, work, summary, checkGreen } = measured(root, slug, intent, detail, current);
|
|
132
163
|
const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, current));
|
|
133
164
|
const verdict = verdictLine(ladder, worst);
|
|
@@ -135,23 +166,105 @@ export function handoff(cwd, args = []) {
|
|
|
135
166
|
const changed = counts.added + counts.removed;
|
|
136
167
|
// The patch comes first so `· inline below` is a promise the fence keeps: a
|
|
137
168
|
// small change git can still print nothing for (a vanished path) falls back
|
|
138
|
-
// to the bare counts rather than pointing at a fence that never arrives.
|
|
139
|
-
|
|
169
|
+
// to the bare counts rather than pointing at a fence that never arrives. The
|
|
170
|
+
// stamp hashes it at any size, so it is read whenever there is a change.
|
|
171
|
+
const fullPatch = changed > 0 ? diffPatch(root, work) : '';
|
|
172
|
+
const patch = changed <= INLINE_DIFF_MAX ? fullPatch : '';
|
|
140
173
|
const inline = patch.length > 0;
|
|
174
|
+
const diffRow = diffRowValue(counts, inline);
|
|
175
|
+
const stamp = pauseStamp(current, detail, measuredLines(rows, diffRow), fullPatch);
|
|
176
|
+
if (!rest.includes('--full') && readOr(pausePath(root, slug)).trim() === stamp) {
|
|
177
|
+
// The pause the human already read, unchanged: the answer above this line
|
|
178
|
+
// is what they asked for, and the card would push it off the screen.
|
|
179
|
+
const command = reprintCommand(null, explicit, named);
|
|
180
|
+
return emit(root, { tier: 'decision', render: 'reminder', command }, [
|
|
181
|
+
`**Next Up**: Back to ${stepPhrase(current, steps)}`,
|
|
182
|
+
]);
|
|
183
|
+
}
|
|
184
|
+
writePause(root, slug, stamp);
|
|
141
185
|
const readout = recapLines(rows, {
|
|
142
|
-
diff:
|
|
186
|
+
diff: diffRow,
|
|
143
187
|
spent: spentRowValue(spentInputs(root, slug, current, summary)),
|
|
144
188
|
constraints: parseConstraintCount(intent),
|
|
145
189
|
});
|
|
146
|
-
const parts = [
|
|
190
|
+
const parts = [
|
|
191
|
+
`**Now**: ${stepPhrase(current, steps)}`,
|
|
192
|
+
summaryBlock(parseSummary(detail), relative(root, detailPath(root))),
|
|
193
|
+
];
|
|
147
194
|
if (readout.length > 0) {
|
|
148
|
-
parts.push([
|
|
195
|
+
parts.push([READOUT_LABEL, '', ...fence('text', readout)].join('\n'));
|
|
149
196
|
}
|
|
150
197
|
if (inline) {
|
|
151
198
|
parts.push(fence('diff', patch.split('\n')).join('\n'));
|
|
152
199
|
}
|
|
153
|
-
parts.push(verdict,
|
|
154
|
-
|
|
200
|
+
parts.push(verdict, nextUpBlock(nextUp, steps.length, where, nextUpNote(intent, buildLog, current)), yourCallBlock(current, checkGreen));
|
|
201
|
+
const command = reprintCommand('--full', explicit, named);
|
|
202
|
+
return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The detail file's text with a blank line around each heading, written back to
|
|
206
|
+
* disk when that changed anything, so the file the gate lints before
|
|
207
|
+
* `checkpoint` clears it is clean whatever the model typed.
|
|
208
|
+
*
|
|
209
|
+
* The render goes out on the normalized text either way. A failed write costs
|
|
210
|
+
* only the lint guard: stdout and the exit code are the same as if it had
|
|
211
|
+
* landed.
|
|
212
|
+
*/
|
|
213
|
+
function settleDetail(root, raw) {
|
|
214
|
+
const clean = normalizeDetailHeadings(raw);
|
|
215
|
+
if (clean !== raw) {
|
|
216
|
+
try {
|
|
217
|
+
writeFileSync(detailPath(root), clean);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Read-only tree or a vanished directory: the pause still renders.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return clean;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The stamp of a step pause: its number and a hash of everything on the card a
|
|
227
|
+
* later turn could change, so the reminder prints only while the pause is the
|
|
228
|
+
* one the human already read.
|
|
229
|
+
*
|
|
230
|
+
* Hashed: the detail file as normalized, the measured check and seam rows and
|
|
231
|
+
* the diff row as rendered, and the patch the diff row counts, so an edit that
|
|
232
|
+
* keeps the counts still reads as a touched file. Never `spent`: it counts
|
|
233
|
+
* turns, so it moves on every turn and would make every render look new.
|
|
234
|
+
*/
|
|
235
|
+
function pauseStamp(step, detail, rendered, patch) {
|
|
236
|
+
const hash = createHash('sha256');
|
|
237
|
+
for (const part of [detail, ...rendered, patch]) {
|
|
238
|
+
hash.update(part).update('\0');
|
|
239
|
+
}
|
|
240
|
+
return `${step} ${hash.digest('hex')}`;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The readout rows the CLI measured, as the card renders them: `check`,
|
|
244
|
+
* `seam`, and `diff`. The judgment rows ride the detail file, which the stamp
|
|
245
|
+
* hashes whole, and `spent` stays out of it.
|
|
246
|
+
*/
|
|
247
|
+
function measuredLines(rows, diff) {
|
|
248
|
+
const measuredRows = {};
|
|
249
|
+
if (rows.check !== undefined) {
|
|
250
|
+
measuredRows.check = rows.check;
|
|
251
|
+
}
|
|
252
|
+
if (rows.seam !== undefined) {
|
|
253
|
+
measuredRows.seam = rows.seam;
|
|
254
|
+
}
|
|
255
|
+
return recapLines(measuredRows, { diff, spent: null, constraints: 0 });
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Record the stamp of the card just printed. A failed write costs one more
|
|
259
|
+
* full card on a later turn, never this turn's output or its exit code.
|
|
260
|
+
*/
|
|
261
|
+
function writePause(root, slug, stamp) {
|
|
262
|
+
try {
|
|
263
|
+
writeFileSync(pausePath(root, slug), `${stamp}\n`);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// A read-only tree or a vanished build folder: the card still renders.
|
|
267
|
+
}
|
|
155
268
|
}
|
|
156
269
|
/**
|
|
157
270
|
* The two CLI-rendered parts of a boundary ending: the Verdict folded from
|
|
@@ -165,11 +278,12 @@ export function handoff(cwd, args = []) {
|
|
|
165
278
|
*/
|
|
166
279
|
export function boundaryEnding(root, slug, step) {
|
|
167
280
|
const intent = readOr(intentPath(root, slug));
|
|
168
|
-
const steps =
|
|
281
|
+
const steps = parseOrderedSteps(intent);
|
|
169
282
|
const nextUp = steps.find((s) => !s.done && s.n !== step);
|
|
283
|
+
const note = nextUpNote(intent, readOr(buildLogPath(root, slug)), step);
|
|
170
284
|
return {
|
|
171
285
|
verdict: step === null ? null : landedVerdict(root, slug, intent, step),
|
|
172
|
-
pointer:
|
|
286
|
+
pointer: nextUpBlock(nextUp, steps.length, relative(root, intentPath(root, slug)), note),
|
|
173
287
|
};
|
|
174
288
|
}
|
|
175
289
|
/**
|
|
@@ -179,6 +293,9 @@ export function boundaryEnding(root, slug, step) {
|
|
|
179
293
|
* numbered sections' bodies, blank-line separated and unindented; the caller
|
|
180
294
|
* nests it under the Log's dated line.
|
|
181
295
|
*
|
|
296
|
+
* The record carries no Now line, and its Readout label stays bare like the
|
|
297
|
+
* card's: the dated Log line above it already names the step.
|
|
298
|
+
*
|
|
182
299
|
* Measured, not recalled: the check, seam, and diff rows are read the same way
|
|
183
300
|
* the pause read them. Both measures run against HEAD, so staging no longer
|
|
184
301
|
* moves the counts and the caller's build-then-stage order is a nicety rather
|
|
@@ -208,7 +325,7 @@ export function stepRecord(root, slug, step, detail) {
|
|
|
208
325
|
parts.push(recordSummary(summary));
|
|
209
326
|
}
|
|
210
327
|
if (readout.length > 0) {
|
|
211
|
-
parts.push([
|
|
328
|
+
parts.push([READOUT_LABEL, '', ...fence('text', readout)].join('\n'));
|
|
212
329
|
}
|
|
213
330
|
parts.push(verdictLine(ladder, worst));
|
|
214
331
|
return [...withRecommendation(parts, recommendation), ...sectionBlocks(sections)].join('\n\n');
|
|
@@ -255,9 +372,11 @@ function sectionBlocks(sections) {
|
|
|
255
372
|
*/
|
|
256
373
|
export function driverPointer(root, slug) {
|
|
257
374
|
const intent = readOr(intentPath(root, slug));
|
|
258
|
-
|
|
375
|
+
// The forward fallback follows the build order like every other pick; the
|
|
376
|
+
// driver line itself stays one line, with no note beneath it.
|
|
377
|
+
const steps = parseOrderedSteps(intent);
|
|
259
378
|
const inFlight = readMarker(stepPath(root, slug));
|
|
260
|
-
const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight
|
|
379
|
+
const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight);
|
|
261
380
|
// The forward fallback skips the step just landed as well as the one open, so
|
|
262
381
|
// a checkpoint whose `[x]` flip failed still points on rather than back at
|
|
263
382
|
// work already recorded.
|
|
@@ -297,22 +416,33 @@ function measured(root, slug, intent, detail, step) {
|
|
|
297
416
|
return { rows, work, summary, checkGreen: measuredCheck?.verdict === 'true' };
|
|
298
417
|
}
|
|
299
418
|
/**
|
|
300
|
-
*
|
|
419
|
+
* Print an ending, record it for the relay check, and return handoff's exit
|
|
420
|
+
* code.
|
|
301
421
|
*
|
|
302
422
|
* The parts are stacked by the same assembly a transition verb prints its own
|
|
303
423
|
* ending through, so the block reads the same whichever command emitted it.
|
|
304
424
|
*/
|
|
305
|
-
function emit(parts) {
|
|
306
|
-
|
|
425
|
+
function emit(root, kind, parts) {
|
|
426
|
+
printEnding(root, { ...kind, text: blocks(parts) });
|
|
307
427
|
return 0;
|
|
308
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* The command that prints a decision ending again, for the retry the relay
|
|
431
|
+
* check asks for: handoff in the form that printed it, with the step a verify
|
|
432
|
+
* pause was named by and the build a flag named, so the retry lands on the
|
|
433
|
+
* same pause with nothing in flight and the cursor elsewhere.
|
|
434
|
+
*/
|
|
435
|
+
function reprintCommand(form, step, build) {
|
|
436
|
+
const parts = ['plumbbob handoff', form, step, build === null ? null : `--build ${build}`];
|
|
437
|
+
return parts.filter((p) => p !== null && p !== undefined).join(' ');
|
|
438
|
+
}
|
|
309
439
|
/**
|
|
310
440
|
* The Verdict line from an already-folded result: the ladder rung, and the
|
|
311
441
|
* worst component in a trailing parenthetical when one exists.
|
|
312
442
|
*
|
|
313
|
-
* No step segment rides here. The identity renders once per turn: the
|
|
314
|
-
*
|
|
315
|
-
* left saying the one thing only it says.
|
|
443
|
+
* No step segment rides here. The identity renders once per turn: the Now line
|
|
444
|
+
* names the step in flight with its count, Next Up names the step to come with
|
|
445
|
+
* its own, and the Verdict is left saying the one thing only it says.
|
|
316
446
|
*/
|
|
317
447
|
function verdictLine(ladder, worst) {
|
|
318
448
|
const state = `**Verdict**: ${ladder.glyph} ${ladder.state}`;
|
|
@@ -338,13 +468,26 @@ function summaryBlock(summary, where) {
|
|
|
338
468
|
}
|
|
339
469
|
return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
|
|
340
470
|
}
|
|
471
|
+
// The Readout's label, bare on the card and in the record alike: the Now line
|
|
472
|
+
// at the top of the card names the step the fence measures, and a label that
|
|
473
|
+
// named it too would put the step on the card twice.
|
|
474
|
+
const READOUT_LABEL = '**Readout**:';
|
|
341
475
|
/**
|
|
342
|
-
*
|
|
476
|
+
* A step named the way every pointer names it: the number, the count out of
|
|
477
|
+
* the whole plan, and the title behind a plain hyphen.
|
|
478
|
+
*
|
|
479
|
+
* The count is the plan's own, step number over total, so the Now line and
|
|
480
|
+
* Next Up agree under a re-sequenced build order. It is dropped only where it
|
|
481
|
+
* would lie: a step the plan no longer holds is its number alone, since
|
|
482
|
+
* "Step 9 of 3" would be worse than no count.
|
|
343
483
|
*/
|
|
344
|
-
function
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
484
|
+
function stepPhrase(n, steps) {
|
|
485
|
+
const step = steps.find((s) => s.n === n);
|
|
486
|
+
if (step === undefined) {
|
|
487
|
+
return `Step ${n}`;
|
|
488
|
+
}
|
|
489
|
+
const title = step.title.length > 0 ? ` - ${step.title}` : '';
|
|
490
|
+
return `Step ${n} of ${steps.length}${title}`;
|
|
348
491
|
}
|
|
349
492
|
/**
|
|
350
493
|
* What the `spent` row is rendered from: the step's own stamps and counters
|
|
@@ -449,16 +592,18 @@ function readCheckSummary(root) {
|
|
|
449
592
|
*
|
|
450
593
|
* Nobody types "needs work", so the block names what really happens instead:
|
|
451
594
|
* a message that asks is an expand (nothing changes), and a message that
|
|
452
|
-
* directs is the fix.
|
|
453
|
-
*
|
|
454
|
-
*
|
|
595
|
+
* directs is the fix. The expand move also names the way back to the card,
|
|
596
|
+
* since an expand turn ends on the reminder and the reminder states no move.
|
|
597
|
+
* `revert` comes last, because a destructive move is named rather than
|
|
598
|
+
* discovered. `looks good` renders only while the measured check is green;
|
|
599
|
+
* offering a move that would refuse teaches a false ceremony.
|
|
455
600
|
*/
|
|
456
601
|
function yourCallBlock(step, checkGreen) {
|
|
457
602
|
const moves = [];
|
|
458
603
|
if (checkGreen) {
|
|
459
604
|
moves.push(callLine('`looks good`', `I checkpoint Step ${step}; back to the boundary`));
|
|
460
605
|
}
|
|
461
|
-
moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes'));
|
|
606
|
+
moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes, and `show the pause` brings all of this back'));
|
|
462
607
|
moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
|
|
463
608
|
moves.push(callLine('`revert`', 'I wind the work back to the last checkpoint'));
|
|
464
609
|
return ['**Your Call**:', '', ...moves].join('\n');
|
|
@@ -493,16 +638,8 @@ function planCallBlock(first) {
|
|
|
493
638
|
* step is already being built, so the `/model` call is behind us. Null when no
|
|
494
639
|
* step is open, which is the caller's cue to fall back to the forward pointer.
|
|
495
640
|
*/
|
|
496
|
-
function driverNextUpLine(steps, inFlight
|
|
497
|
-
|
|
498
|
-
return null;
|
|
499
|
-
}
|
|
500
|
-
const open = steps.find((s) => s.n === inFlight);
|
|
501
|
-
const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
|
|
502
|
-
// The progress count rides every tier's pointer, but only where the plan
|
|
503
|
-
// actually holds the step: "step 9 of 3" would be worse than no count.
|
|
504
|
-
const progress = open === undefined ? '' : ` of ${total}`;
|
|
505
|
-
return `**Next Up**: Back to Step ${inFlight}${progress}${title}`;
|
|
641
|
+
function driverNextUpLine(steps, inFlight) {
|
|
642
|
+
return inFlight === null ? null : `**Next Up**: Back to ${stepPhrase(inFlight, steps)}`;
|
|
506
643
|
}
|
|
507
644
|
/**
|
|
508
645
|
* The driver pointer while a spike is open: the spike outranks the step it
|
|
@@ -517,6 +654,38 @@ function spikeNextUpLine(inFlight) {
|
|
|
517
654
|
const back = inFlight === null ? '' : `, then back to Step ${inFlight}`;
|
|
518
655
|
return `**Next Up**: Close the spike - /plumbbob:spike done${back}`;
|
|
519
656
|
}
|
|
657
|
+
// The card's cap on the build-order reminder: the next five steps, the rest a
|
|
658
|
+
// count. The line is a reminder; the dashboard holds the whole sequence.
|
|
659
|
+
const ORDER_CAP = 5;
|
|
660
|
+
/**
|
|
661
|
+
* The line beneath the forward pointer: the build order from the step it
|
|
662
|
+
* names, when `## Build order` re-sequences the plan away from its numbering,
|
|
663
|
+
* and the park count, when anything has been parked; '' when neither says
|
|
664
|
+
* anything, so a plan nobody re-sequenced with an empty park list renders the
|
|
665
|
+
* pointer alone, as before.
|
|
666
|
+
*
|
|
667
|
+
* Indented two spaces, because an indented line continues the part above it
|
|
668
|
+
* to every reader of the anatomy (the advisory's `→` remedy line is the
|
|
669
|
+
* precedent), where a flush one would open a stray. The parks ride even under
|
|
670
|
+
* `Nothing planned`: an unharvested list at the end of the plan is the one
|
|
671
|
+
* reminder that matters there.
|
|
672
|
+
*/
|
|
673
|
+
function nextUpNote(intent, buildLog, exclude) {
|
|
674
|
+
const parks = parseParkTotals(buildLog);
|
|
675
|
+
const segments = [
|
|
676
|
+
buildOrderPhrase(remainingOrder(parseSteps(intent), parseBuildOrder(intent), exclude), ORDER_CAP),
|
|
677
|
+
parks.total > 0 ? parkedPhrase(parks) : '',
|
|
678
|
+
].filter((s) => s.length > 0);
|
|
679
|
+
return segments.length === 0 ? '' : ` · ${segments.join(' · ')}`;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* The forward pointer with its note beneath, as one part: `blocks` keeps a
|
|
683
|
+
* part's own newlines, so the note rides flush under the line it qualifies.
|
|
684
|
+
*/
|
|
685
|
+
function nextUpBlock(nextUp, total, where, note) {
|
|
686
|
+
const line = nextUpLine(nextUp, total, where);
|
|
687
|
+
return note.length === 0 ? line : `${line}\n${note}`;
|
|
688
|
+
}
|
|
520
689
|
/**
|
|
521
690
|
* The forward pointer: the next undone step with the progress count, its
|
|
522
691
|
* title, and a closing bracket carrying its advisory `- model:` recommendation
|
|
@@ -526,7 +695,8 @@ function spikeNextUpLine(inFlight) {
|
|
|
526
695
|
* The model is the second bold token the line spends, because it is the one
|
|
527
696
|
* the human acts on (a `/model` call) before the next run; the path is a bare
|
|
528
697
|
* `path:line` in a code span, the one link form that opens in a host and still
|
|
529
|
-
* reads as a path in a PR diff.
|
|
698
|
+
* reads as a path in a PR diff. The line alone is the driver tier's pointer;
|
|
699
|
+
* every other tier rides `nextUpNote` beneath it, through `nextUpBlock`.
|
|
530
700
|
*/
|
|
531
701
|
function nextUpLine(nextUp, total, where) {
|
|
532
702
|
if (nextUp === undefined) {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `plumbbob order <n>…`: write the build order, the sequence to build the
|
|
2
|
+
// remaining steps in, as the one numeric line under `## Build order` in
|
|
3
|
+
// intent.md. A refined plan appends steps whose numbers say nothing about when
|
|
4
|
+
// they land (a step 7 that has to go before step 5), and until this verb the
|
|
5
|
+
// human carried that sequence in their head and typed it into every `build`.
|
|
6
|
+
// The line is what every next-step picker reads (`status`, `build`,
|
|
7
|
+
// `checkpoint`, `handoff`), so the dashboard's `← next`, a bare
|
|
8
|
+
// `plumbbob build`, and the card's Next Up agree. The verb validates what a
|
|
9
|
+
// hand edit cannot: every number names an undone step, none twice. `--reset`
|
|
10
|
+
// drops the line, and the plan reads in document order again. It runs in any
|
|
11
|
+
// state, since re-sequencing what comes after the step in flight is the
|
|
12
|
+
// mid-step use, and the sidecar sits outside every seam.
|
|
13
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { findRepoRoot } from "../lib/git.js";
|
|
15
|
+
import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
|
|
16
|
+
import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
|
|
17
|
+
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
18
|
+
import { driverPointer } from "./handoff.js";
|
|
19
|
+
/**
|
|
20
|
+
* Set the build order to the given step numbers, the rest of the undone steps
|
|
21
|
+
* following in document order, or clear it with `--reset`.
|
|
22
|
+
*
|
|
23
|
+
* The line written is the whole remaining sequence, so a cold reader of
|
|
24
|
+
* intent.md sees every undone step and not only the ones re-sequenced.
|
|
25
|
+
* Refuses (exit 1) with no session, no numbers, a token that is not a number,
|
|
26
|
+
* a number the plan lacks, a repeated number, or a step already checkpointed.
|
|
27
|
+
*/
|
|
28
|
+
export function order(cwd, args) {
|
|
29
|
+
const root = findRepoRoot(cwd);
|
|
30
|
+
if (root === null || !hasSession(root)) {
|
|
31
|
+
process.stderr.write(notice({ fact: 'no active session', detail: ['no plan to order'], remedy: 'plumbbob start "<title>"' }));
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
const path = intentPath(root);
|
|
35
|
+
const intent = readFileSync(path, 'utf8');
|
|
36
|
+
if (args.includes('--reset')) {
|
|
37
|
+
writeFileSync(path, clearBuildOrder(intent));
|
|
38
|
+
printEnding(root, {
|
|
39
|
+
tier: 'driver',
|
|
40
|
+
render: 'order',
|
|
41
|
+
text: ending({
|
|
42
|
+
lead: transition({ label: 'Build order', fact: 'document order' }),
|
|
43
|
+
pointer: driverPointer(root, activeBuild(root)),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
// Numbers arrive space- or comma-separated: `order 7 8 5` and `order 7,8,5`
|
|
49
|
+
// read the same, since the line they write is the comma form.
|
|
50
|
+
const tokens = args
|
|
51
|
+
.filter((a) => !a.startsWith('--'))
|
|
52
|
+
.flatMap((a) => a.split(','))
|
|
53
|
+
.map((t) => t.trim())
|
|
54
|
+
.filter((t) => t.length > 0);
|
|
55
|
+
if (tokens.length === 0) {
|
|
56
|
+
process.stderr.write(notice({
|
|
57
|
+
fact: 'order needs step numbers',
|
|
58
|
+
detail: ['the undone steps, in the sequence to build them'],
|
|
59
|
+
remedy: 'plumbbob order 7 8 5, or plumbbob order --reset for document order',
|
|
60
|
+
}));
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
const stray = tokens.find((t) => !/^\d+$/.test(t));
|
|
64
|
+
if (stray !== undefined) {
|
|
65
|
+
process.stderr.write(notice({ fact: `\`${stray}\` is not a step number`, remedy: 'plumbbob order 7 8 5' }));
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
const given = tokens.map(Number);
|
|
69
|
+
const steps = parseSteps(intent);
|
|
70
|
+
const planned = steps.map((s) => s.n);
|
|
71
|
+
const unknown = given.find((n) => !planned.includes(n));
|
|
72
|
+
if (unknown !== undefined) {
|
|
73
|
+
process.stderr.write(notice({
|
|
74
|
+
fact: `no step ${unknown} in the plan`,
|
|
75
|
+
detail: [planned.length === 0 ? 'no steps planned yet' : `planned: ${planned.join(', ')}`],
|
|
76
|
+
remedy: 'fix the number, or /plumbbob:step to add the step first',
|
|
77
|
+
}));
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const repeat = given.find((n, i) => given.indexOf(n) !== i);
|
|
81
|
+
if (repeat !== undefined) {
|
|
82
|
+
process.stderr.write(notice({ fact: `step ${repeat} is listed twice`, remedy: 'list each step once' }));
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const landed = given.find((n) => steps.find((s) => s.n === n)?.done === true);
|
|
86
|
+
if (landed !== undefined) {
|
|
87
|
+
process.stderr.write(notice({
|
|
88
|
+
fact: `step ${landed} is already checkpointed`,
|
|
89
|
+
detail: ['the order sequences what is left'],
|
|
90
|
+
remedy: 'list the undone steps only',
|
|
91
|
+
}));
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
const full = orderSteps(steps, given)
|
|
95
|
+
.filter((s) => !s.done)
|
|
96
|
+
.map((s) => s.n);
|
|
97
|
+
writeFileSync(path, setBuildOrder(intent, full));
|
|
98
|
+
// The pointer reads the plan back from disk, so it already follows the
|
|
99
|
+
// order just written: back at the step in flight, or forward from the
|
|
100
|
+
// boundary to the first step in the new sequence.
|
|
101
|
+
printEnding(root, {
|
|
102
|
+
tier: 'driver',
|
|
103
|
+
render: 'order',
|
|
104
|
+
text: ending({
|
|
105
|
+
lead: transition({ label: 'Build order', fact: full.join(', ') }),
|
|
106
|
+
pointer: driverPointer(root, activeBuild(root)),
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
package/dist/verbs/park.js
CHANGED
|
@@ -11,7 +11,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
11
11
|
import { findRepoRoot } from "../lib/git.js";
|
|
12
12
|
import { activeBuild, hasSession, buildLogPath } from "../lib/sidecar.js";
|
|
13
13
|
import { appendToSection } from "../lib/buildlog.js";
|
|
14
|
-
import { ending, notice, transition } from "../lib/notice.js";
|
|
14
|
+
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
15
15
|
import { driverPointer } from "./handoff.js";
|
|
16
16
|
/**
|
|
17
17
|
* Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
|
|
@@ -43,6 +43,10 @@ export function park(cwd, args) {
|
|
|
43
43
|
writeFileSync(path, updated);
|
|
44
44
|
// A park interrupts a step without ending it, so the ending is the capture
|
|
45
45
|
// and the pointer back at what was interrupted.
|
|
46
|
-
|
|
46
|
+
printEnding(root, {
|
|
47
|
+
tier: 'driver',
|
|
48
|
+
render: 'park',
|
|
49
|
+
text: ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }),
|
|
50
|
+
});
|
|
47
51
|
return 0;
|
|
48
52
|
}
|
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 { parseSteps } from "../lib/orient.js";
|
|
21
|
+
import { parseBuildOrder, parseSteps } 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
|
/**
|
|
@@ -93,9 +93,39 @@ function sessionFindings(root) {
|
|
|
93
93
|
findings.push({ name: 'cursor', ok: true, detail: `build "${slug}" resolves` });
|
|
94
94
|
const stepInFlight = existsSync(stepPath(root, slug));
|
|
95
95
|
findings.push(...phaseFindings(root, slug, stepInFlight));
|
|
96
|
+
findings.push(...orderFindings(root, slug));
|
|
96
97
|
findings.push(...leftoverFindings(root, slug, stepInFlight));
|
|
97
98
|
return findings;
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* A build order naming a step the plan does not hold.
|
|
102
|
+
*
|
|
103
|
+
* The line under `## Build order` is hand-editable, and a refine can renumber
|
|
104
|
+
* or drop the step it names; every picker just skips the number, so the plan
|
|
105
|
+
* quietly builds in a sequence nobody wrote down. Reported, never repaired:
|
|
106
|
+
* intent.md is a tracked artifact, and the sequence is a judgment call. Silent
|
|
107
|
+
* while the plan declares no order at all.
|
|
108
|
+
*/
|
|
109
|
+
function orderFindings(root, slug) {
|
|
110
|
+
const intent = readOr(intentPath(root, slug));
|
|
111
|
+
const order = parseBuildOrder(intent);
|
|
112
|
+
if (order.length === 0) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const planned = parseSteps(intent).map((s) => s.n);
|
|
116
|
+
const unknown = order.filter((n) => !planned.includes(n));
|
|
117
|
+
if (unknown.length === 0) {
|
|
118
|
+
return [{ name: 'build order', ok: true, detail: `every step it lists is planned (${order.join(', ')})` }];
|
|
119
|
+
}
|
|
120
|
+
return [
|
|
121
|
+
{
|
|
122
|
+
name: 'build order',
|
|
123
|
+
ok: false,
|
|
124
|
+
detail: `the build order names step${unknown.length === 1 ? '' : 's'} ${unknown.join(', ')}, which the plan does not hold (planned: ${planned.join(', ')})`,
|
|
125
|
+
hint: 'rewrite the line with plumbbob order <n>…, or plumbbob order --reset to read the plan in document order',
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
}
|
|
99
129
|
/**
|
|
100
130
|
* The cursor names a build that is not there.
|
|
101
131
|
*
|