plumbbob 0.10.3 → 0.11.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/dist/cli-core.js +8 -4
- package/dist/lib/agents.js +2 -2
- package/dist/lib/git.js +60 -5
- package/dist/lib/notice.js +140 -0
- package/dist/lib/orient.js +437 -8
- package/dist/lib/sidecar.js +28 -0
- package/dist/verbs/abandon.js +25 -8
- package/dist/verbs/agent.js +63 -31
- package/dist/verbs/build.js +25 -9
- package/dist/verbs/check.js +8 -3
- package/dist/verbs/checkpoint.js +96 -23
- package/dist/verbs/finish.js +33 -9
- package/dist/verbs/handoff.js +432 -63
- package/dist/verbs/park.js +11 -5
- package/dist/verbs/revert.js +16 -5
- package/dist/verbs/spike.js +61 -22
- package/dist/verbs/start.js +56 -17
- package/dist/verbs/use.js +26 -16
- package/package.json +1 -1
- package/skills/abandon/SKILL.md +2 -1
- package/skills/build/SKILL.md +116 -35
- package/skills/finish/SKILL.md +19 -8
- package/skills/park/SKILL.md +15 -1
- package/skills/plan/SKILL.md +14 -1
- package/skills/recover/SKILL.md +3 -2
- package/skills/revert/SKILL.md +2 -1
- package/skills/spike/SKILL.md +3 -2
- package/skills/status/SKILL.md +13 -5
- package/skills/verify/SKILL.md +127 -31
package/dist/verbs/handoff.js
CHANGED
|
@@ -1,23 +1,71 @@
|
|
|
1
|
-
// `plumbbob handoff`:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
1
|
+
// `plumbbob handoff`: emit the CLI-owned ending of a turn as one contiguous
|
|
2
|
+
// block (docs/presentation.md). Read-only, no state change. Every part outside
|
|
3
|
+
// the readout fence is a bold label, a colon, and text that wraps, one blank
|
|
4
|
+
// 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 labeled
|
|
6
|
+
// Readout and its fence, the inline diff when the change is 20 lines or fewer,
|
|
7
|
+
// the Verdict, Next Up, Your Call, and the Recommendation last); a landed step
|
|
8
|
+
// with none in flight ⇒ the orientation-tier ending (Verdict and Next Up); a
|
|
9
|
+
// fresh session with nothing yet measured ⇒ the forward pointer alone.
|
|
10
|
+
//
|
|
11
|
+
// The whole decision turn is rendered here, its opening block included: the
|
|
12
|
+
// Summary lead and the numbered highlights come out of `.plumbbob/detail.md`
|
|
13
|
+
// as the markdown the model wrote, with the `(details: …)` bracket appended so
|
|
14
|
+
// the model never types a path. A region the model does not author is a region
|
|
15
|
+
// it cannot narrate into.
|
|
16
|
+
//
|
|
17
|
+
// If the CLI can compute a recap row, the CLI does: the check row comes from
|
|
18
|
+
// the last run's summary, the seam row from the SEAM marker against the
|
|
19
|
+
// work-plane diff, the diff row from `git diff --numstat`. The model's part
|
|
20
|
+
// (`.plumbbob/detail.md`) supplies only what takes judgment: the Summary, the
|
|
21
|
+
// three judgment rows, and the recommendation; the Verdict folds the same
|
|
22
|
+
// assembled rows worst-of with the step's accrued stats, so the fence shows
|
|
23
|
+
// exactly what the fold saw.
|
|
24
|
+
//
|
|
25
|
+
// Every tier's ending is emitted here, so no skill has to fake the furniture in
|
|
26
|
+
// prose: `--plan` renders the plan-pause ending and `--driver` the driver
|
|
27
|
+
// turn's pointer, the two endings the session state cannot tell apart from the
|
|
28
|
+
// ones above. The driver pointer reads the phase it lands in: a spike open over
|
|
29
|
+
// the step names closing the spike, a step exit (a revert, an abandon, a spike
|
|
30
|
+
// closed at the boundary) finds nothing in flight and points forward, with no
|
|
31
|
+
// Verdict either way, since nothing landed.
|
|
10
32
|
import { readFileSync } from 'node:fs';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
33
|
+
import { join, relative } from 'node:path';
|
|
34
|
+
import { commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
|
|
35
|
+
import { blocks } from "../lib/notice.js";
|
|
36
|
+
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, parseSteps, parseSummary, recapLines, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
|
|
39
|
+
// The inline-diff threshold (docs/presentation.md): at 20 changed lines or
|
|
40
|
+
// fewer the patch rides the turn; at 21 it stays in the working tree behind
|
|
41
|
+
// the counted diff row.
|
|
42
|
+
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
|
+
// The seam rule, and the one tier that still has a seam to mark: at the plan
|
|
49
|
+
// pause the model presents the framed plan above the relay, so the rule keeps
|
|
50
|
+
// a label from reading as the tail of what precedes it, and a run of blank
|
|
51
|
+
// lines collapses to one in every markdown renderer where a thematic break
|
|
52
|
+
// renders as space. The blank line above it matters: `---` flush under a text
|
|
53
|
+
// line turns that line into a heading (the underline form), not a rule. The
|
|
54
|
+
// step pause needs none: handoff renders that whole turn, Summary included.
|
|
55
|
+
const SEAM_RULE = '\n---';
|
|
14
56
|
/**
|
|
15
|
-
* Print the
|
|
57
|
+
* Print the resolved build's turn ending; return the exit code.
|
|
16
58
|
*
|
|
17
|
-
* Requires an active session (the STATE sentinel under `.plumbbob/`).
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
59
|
+
* Requires an active session (the STATE sentinel under `.plumbbob/`). The step
|
|
60
|
+
* tiers are derived, not passed: a step in flight yields the full
|
|
61
|
+
* decision-tier ending (the Readout and its fence, the inline diff when small
|
|
62
|
+
* enough, the Verdict, Next Up, Your Call, and the Recommendation), a landed
|
|
63
|
+
* step yields the orientation-tier ending (Verdict and Next Up, no Your Call),
|
|
64
|
+
* and a fresh session with nothing to report yields only
|
|
65
|
+
* the forward pointer. The two endings no session state can distinguish are
|
|
66
|
+
* named by a flag: `--plan` is the plan pause (a decision turn about the plan,
|
|
67
|
+
* not a diff) and `--driver` is a driver turn (a park, a spike, a `use`), which
|
|
68
|
+
* interrupts a step without ending it.
|
|
21
69
|
*/
|
|
22
70
|
export function handoff(cwd, args = []) {
|
|
23
71
|
const root = findRepoRoot(cwd);
|
|
@@ -26,8 +74,25 @@ export function handoff(cwd, args = []) {
|
|
|
26
74
|
return 1;
|
|
27
75
|
}
|
|
28
76
|
const { build: slug, rest } = resolveBuild(root, args);
|
|
29
|
-
|
|
30
|
-
|
|
77
|
+
if (rest.includes('--plan') && rest.includes('--driver')) {
|
|
78
|
+
process.stderr.write('plumbbob: handoff: --plan and --driver name different tiers; pass one.\n');
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
const intent = readOr(intentPath(root, slug));
|
|
82
|
+
const steps = parseSteps(intent);
|
|
83
|
+
const inFlight = readMarker(stepPath(root, slug));
|
|
84
|
+
const detail = readOr(detailPath(root));
|
|
85
|
+
// Where a pointer sends the reader to read the step itself: the intent file
|
|
86
|
+
// as the human would type it, relative to the repo root.
|
|
87
|
+
const where = relative(root, intentPath(root, slug));
|
|
88
|
+
if (rest.includes('--plan')) {
|
|
89
|
+
// The plan pause judges the plan, so nothing is measured and no Verdict
|
|
90
|
+
// renders; the pointer and the moves both aim at the first undone step. A
|
|
91
|
+
// decision turn still ends on the model's recommendation when it wrote one.
|
|
92
|
+
const first = steps.find((s) => !s.done);
|
|
93
|
+
const plan = [SEAM_RULE, nextUpLine(first, steps.length, where), planCallBlock(first)];
|
|
94
|
+
return emit(withRecommendation(plan, parseRecommendation(detail)));
|
|
95
|
+
}
|
|
31
96
|
const explicit = rest.find((a) => /^\d+$/.test(a));
|
|
32
97
|
const lastDone = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
|
|
33
98
|
// The step this hand-off is about: an explicit arg wins (the skill's override),
|
|
@@ -40,70 +105,367 @@ export function handoff(cwd, args = []) {
|
|
|
40
105
|
// so the `!== current` clause is a harmless no-op. Skipping-ahead builds still
|
|
41
106
|
// land on the earliest remaining step, never a gap.
|
|
42
107
|
const nextUp = steps.find((s) => !s.done && s.n !== current);
|
|
108
|
+
if (rest.includes('--driver')) {
|
|
109
|
+
// A driver turn interrupts a step without ending it, so its pointer aims
|
|
110
|
+
// back at the step still open, or at the spike open over it. With neither
|
|
111
|
+
// (a step exit: a revert, an abandon, a spike closed at the boundary) the
|
|
112
|
+
// ordinary forward pointer stands, and no Verdict rides above it: nothing
|
|
113
|
+
// landed to measure.
|
|
114
|
+
return emit([driverPointer(root, slug)]);
|
|
115
|
+
}
|
|
43
116
|
if (current === null) {
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
117
|
+
// Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
|
|
118
|
+
return emit([nextUpLine(nextUp, steps.length, where)]);
|
|
119
|
+
}
|
|
120
|
+
if (inFlight === null) {
|
|
121
|
+
// The boundary: the orientation-tier ending, the state word and the forward
|
|
122
|
+
// pointer, no decision pending and so no Your Call. The same two parts the
|
|
123
|
+
// checkpoint printed for itself, from the same call.
|
|
124
|
+
const landed = boundaryEnding(root, slug, current);
|
|
125
|
+
return emit([landed.verdict, landed.pointer]);
|
|
126
|
+
}
|
|
127
|
+
// The pause: the whole turn as one contiguous block, each part a labeled line
|
|
128
|
+
// 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 Summary the
|
|
130
|
+
// model wrote into the detail file, so there is no seam left to rule off.
|
|
131
|
+
const { rows, work, summary, checkGreen } = measured(root, slug, intent, detail, current);
|
|
132
|
+
const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, current));
|
|
133
|
+
const verdict = verdictLine(ladder, worst);
|
|
134
|
+
const counts = countDiff(work);
|
|
135
|
+
const changed = counts.added + counts.removed;
|
|
136
|
+
const inline = changed > 0 && changed <= INLINE_DIFF_MAX;
|
|
137
|
+
const readout = recapLines(rows, {
|
|
138
|
+
diff: diffRowValue(counts, inline),
|
|
139
|
+
spent: spentRowValue(spentInputs(root, slug, current, summary)),
|
|
140
|
+
constraints: parseConstraintCount(intent),
|
|
141
|
+
});
|
|
142
|
+
const parts = [summaryBlock(parseSummary(detail), relative(root, detailPath(root)))];
|
|
143
|
+
if (readout.length > 0) {
|
|
144
|
+
parts.push([readoutLabel(current, steps), '', ...fence('text', readout)].join('\n'));
|
|
145
|
+
}
|
|
146
|
+
if (inline) {
|
|
147
|
+
const patch = diffPatch(root, work.map((e) => e.path));
|
|
148
|
+
if (patch.length > 0) {
|
|
149
|
+
parts.push(fence('diff', patch.split('\n')).join('\n'));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
parts.push(verdict, nextUpLine(nextUp, steps.length, where), yourCallBlock(current, checkGreen));
|
|
153
|
+
return emit(withRecommendation(parts, parseRecommendation(detail)));
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The two CLI-rendered parts of a boundary ending: the Verdict folded from
|
|
157
|
+
* everything measurable right now, and the pointer at the step to come.
|
|
158
|
+
*
|
|
159
|
+
* `step` is the step that just landed, or null at a plan commit, where nothing
|
|
160
|
+
* was measured and the Verdict vanishes. A transition verb prints these beneath
|
|
161
|
+
* its own lead line, so a boundary reads identically whether `checkpoint`
|
|
162
|
+
* emitted it or a later `handoff` did; the paths a stray advisory named are the
|
|
163
|
+
* one part that does not survive, and the commit holds those.
|
|
164
|
+
*/
|
|
165
|
+
export function boundaryEnding(root, slug, step) {
|
|
166
|
+
const intent = readOr(intentPath(root, slug));
|
|
167
|
+
const steps = parseSteps(intent);
|
|
168
|
+
const nextUp = steps.find((s) => !s.done && s.n !== step);
|
|
169
|
+
return {
|
|
170
|
+
verdict: step === null ? null : landedVerdict(root, slug, intent, step),
|
|
171
|
+
pointer: nextUpLine(nextUp, steps.length, relative(root, intentPath(root, slug))),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The driver turn's pointer: back at the step still open, at the spike raised
|
|
176
|
+
* over it, or forward from the boundary when the step has already exited.
|
|
177
|
+
*
|
|
178
|
+
* A park or a spike interrupts a step without ending it, so nothing landed and
|
|
179
|
+
* no Verdict rides above this line.
|
|
180
|
+
*/
|
|
181
|
+
export function driverPointer(root, slug) {
|
|
182
|
+
const intent = readOr(intentPath(root, slug));
|
|
183
|
+
const steps = parseSteps(intent);
|
|
184
|
+
const inFlight = readMarker(stepPath(root, slug));
|
|
185
|
+
const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight, steps.length);
|
|
186
|
+
// The forward fallback skips the step just landed as well as the one open, so
|
|
187
|
+
// a checkpoint whose `[x]` flip failed still points on rather than back at
|
|
188
|
+
// work already recorded.
|
|
189
|
+
const landed = parseLastCheckpoint(readOr(checkpointsPath(root, slug)))?.n ?? null;
|
|
190
|
+
const forward = steps.find((s) => !s.done && s.n !== (inFlight ?? landed));
|
|
191
|
+
return driver ?? nextUpLine(forward, steps.length, relative(root, intentPath(root, slug)));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The Verdict for a step that just landed: the assembled rows folded worst-of
|
|
195
|
+
* with the step's accrued stats, rendered as the line.
|
|
196
|
+
*/
|
|
197
|
+
function landedVerdict(root, slug, intent, step) {
|
|
198
|
+
const { rows } = measured(root, slug, intent, readOr(detailPath(root)), step);
|
|
199
|
+
const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, step));
|
|
200
|
+
return verdictLine(ladder, worst);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Assemble the rows once, so the fence and the fold read the same set: the
|
|
204
|
+
* model's attested rows, with the check and seam rows replaced by the CLI's own
|
|
205
|
+
* measurements where one exists (measured beats attested).
|
|
206
|
+
*
|
|
207
|
+
* `checkGreen` reports the *measured* check alone. A move offered on an
|
|
208
|
+
* attested green would be a move the gate then refuses.
|
|
209
|
+
*/
|
|
210
|
+
function measured(root, slug, intent, detail, step) {
|
|
211
|
+
const summary = readCheckSummary(root);
|
|
212
|
+
const measuredCheck = summary === null ? null : summaryCheckRow(summary);
|
|
213
|
+
const work = diffNumstat(root).filter((e) => !isArtifactPath(e.path));
|
|
214
|
+
const rows = { ...(parseRecap(detail)?.rows ?? {}) };
|
|
215
|
+
if (measuredCheck !== null) {
|
|
216
|
+
rows.check = measuredCheck;
|
|
47
217
|
}
|
|
48
|
-
const
|
|
49
|
-
|
|
218
|
+
const measuredSeam = seamRowFromDiff(work.map((e) => e.path), seamTokens(root, slug, intent, step));
|
|
219
|
+
if (measuredSeam !== null) {
|
|
220
|
+
rows.seam = measuredSeam;
|
|
221
|
+
}
|
|
222
|
+
return { rows, work, summary, checkGreen: measuredCheck?.verdict === 'true' };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Write an ending to stdout and return handoff's exit code.
|
|
226
|
+
*
|
|
227
|
+
* The parts are stacked by the same assembly a transition verb prints its own
|
|
228
|
+
* ending through, so the block reads the same whichever command emitted it.
|
|
229
|
+
*/
|
|
230
|
+
function emit(parts) {
|
|
231
|
+
process.stdout.write(blocks(parts));
|
|
50
232
|
return 0;
|
|
51
233
|
}
|
|
52
234
|
/**
|
|
53
|
-
* The
|
|
235
|
+
* The Verdict line from an already-folded result: the ladder rung, and the
|
|
236
|
+
* worst component in a trailing parenthetical when one exists.
|
|
54
237
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
238
|
+
* No step segment rides here. The identity renders once per turn: the Readout
|
|
239
|
+
* label names the step, Next Up carries the progress count, and the Verdict is
|
|
240
|
+
* left saying the one thing only it says.
|
|
57
241
|
*/
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
` - looks good → I'll checkpoint it (lands step ${step} as its own commit, back to the boundary)`,
|
|
62
|
-
` - needs work → tell me what to change and I'll iterate on this same step`,
|
|
63
|
-
'',
|
|
64
|
-
nextUpLine(nextUp),
|
|
65
|
-
].join('\n');
|
|
242
|
+
function verdictLine(ladder, worst) {
|
|
243
|
+
const state = `**Verdict**: ${ladder.glyph} ${ladder.state}`;
|
|
244
|
+
return worst === null ? state : `${state} (${worst})`;
|
|
66
245
|
}
|
|
67
246
|
/**
|
|
68
|
-
* The
|
|
69
|
-
*
|
|
247
|
+
* The turn's opening block: the model's lead behind the Summary label, with the
|
|
248
|
+
* `(details: …)` bracket appended, then the numbered highlights beneath it.
|
|
249
|
+
*
|
|
250
|
+
* The lead and the titles are the markdown the model wrote, passed through; the
|
|
251
|
+
* label, the bracket, and the numbering are the CLI's, so the model never types
|
|
252
|
+
* a path and the block cannot drift from where the detail actually is. Empty
|
|
253
|
+
* when the detail file carries no lead: a Summary that says nothing vanishes
|
|
254
|
+
* rather than labeling nothing.
|
|
70
255
|
*/
|
|
71
|
-
function
|
|
72
|
-
|
|
256
|
+
function summaryBlock(summary, where) {
|
|
257
|
+
if (summary === null) {
|
|
258
|
+
return '';
|
|
259
|
+
}
|
|
260
|
+
const lead = `**Summary**: ${summary.lead} (details: \`${where}\`)`;
|
|
261
|
+
if (summary.highlights.length === 0) {
|
|
262
|
+
return lead;
|
|
263
|
+
}
|
|
264
|
+
return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
|
|
73
265
|
}
|
|
74
266
|
/**
|
|
75
|
-
* The
|
|
76
|
-
|
|
267
|
+
* The Readout's label: the step the fence below it measures, named once.
|
|
268
|
+
*/
|
|
269
|
+
function readoutLabel(step, steps) {
|
|
270
|
+
const open = steps.find((s) => s.n === step);
|
|
271
|
+
const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
|
|
272
|
+
return `**Readout**: Step ${step}${title}`;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* What the `spent` row is rendered from: the step's own stamps and counters
|
|
276
|
+
* from stats.json, the turns the ledger counted across the step (TURN minus
|
|
277
|
+
* the TICK stamped on entry), and the last gate's wall clock.
|
|
77
278
|
*
|
|
78
|
-
*
|
|
79
|
-
* the
|
|
80
|
-
* finish/step nudge instead.
|
|
279
|
+
* A host with no turn hook grows no ledger, so `turns` comes back null there
|
|
280
|
+
* and the clause simply vanishes rather than reading zero.
|
|
81
281
|
*/
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
|
|
282
|
+
function spentInputs(root, slug, step, summary) {
|
|
283
|
+
const stats = readStats(root, slug)[String(step)] ?? {};
|
|
284
|
+
const turn = readTurn(root);
|
|
285
|
+
const tick = readMarker(tickPath(root, slug));
|
|
286
|
+
return {
|
|
287
|
+
startedAt: stats.startedAt,
|
|
288
|
+
landedAt: stats.landedAt,
|
|
289
|
+
now: Date.now(),
|
|
290
|
+
turns: turn === null || tick === null ? null : Math.max(0, turn - tick),
|
|
291
|
+
redChecks: stats.redChecks ?? 0,
|
|
292
|
+
gateMs: summary?.total_duration_ms ?? null,
|
|
293
|
+
driftWarnings: stats.driftWarnings ?? 0,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The seam tokens governing `step`: the SEAM marker `build` wrote is
|
|
298
|
+
* authoritative while a build is live, else the step's declared seam parsed
|
|
299
|
+
* from intent.md. Empty when neither resolves, which vanishes the measured
|
|
300
|
+
* seam row rather than flagging the whole tree.
|
|
301
|
+
*/
|
|
302
|
+
function seamTokens(root, slug, intent, step) {
|
|
303
|
+
try {
|
|
304
|
+
const fromFile = readFileSync(seamPath(root, slug), 'utf8')
|
|
305
|
+
.split('\n')
|
|
306
|
+
.map((l) => l.trim())
|
|
307
|
+
.filter((l) => l.length > 0);
|
|
308
|
+
if (fromFile.length > 0) {
|
|
309
|
+
return fromFile;
|
|
310
|
+
}
|
|
85
311
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
312
|
+
catch {
|
|
313
|
+
// no SEAM marker: fall through to the declared seam.
|
|
314
|
+
}
|
|
315
|
+
const parsed = parseStepSeam(intent, step);
|
|
316
|
+
return parsed.ok ? parsed.seam : [];
|
|
89
317
|
}
|
|
90
318
|
/**
|
|
91
|
-
*
|
|
319
|
+
* Wrap lines in a markdown fence whose rail outruns any backtick run inside,
|
|
320
|
+
* so a patch that itself contains fence markers cannot break out of the
|
|
321
|
+
* `diff` fence it rides in.
|
|
92
322
|
*/
|
|
93
|
-
function
|
|
94
|
-
|
|
323
|
+
function fence(lang, lines) {
|
|
324
|
+
const longest = lines.reduce((max, l) => Math.max(max, ...(l.match(/`+/g) ?? ['']).map((run) => run.length)), 0);
|
|
325
|
+
const rail = '`'.repeat(Math.max(3, longest + 1));
|
|
326
|
+
return [`${rail}${lang}`, ...lines, rail];
|
|
95
327
|
}
|
|
328
|
+
// The recommendation's label (docs/presentation.md): the one bold token the
|
|
329
|
+
// turn body spends, prepended here so the model never types it.
|
|
330
|
+
const RECOMMENDATION_LABEL = '**Recommendation**: ';
|
|
96
331
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
332
|
+
* Append the recommendation beneath a block, blank-line separated, when the
|
|
333
|
+
* model wrote one; the ending's last text is then the model's own call. The
|
|
334
|
+
* label is the CLI's: it announces what the last text is before the eye
|
|
335
|
+
* reads it, and a label the model typed would be one more line to drift.
|
|
99
336
|
*/
|
|
100
|
-
function
|
|
101
|
-
return
|
|
337
|
+
function withRecommendation(parts, recommendation) {
|
|
338
|
+
return recommendation === null ? [...parts] : [...parts, `${RECOMMENDATION_LABEL}${recommendation}`];
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* The step's accrued stats (seam strays, red checks, reverts) plus commits that
|
|
342
|
+
* landed since the last ledger entry outside plumbbob's checkpoints: the
|
|
343
|
+
* advisory inputs to the Verdict's third fold rung.
|
|
344
|
+
*/
|
|
345
|
+
function accruedStats(root, slug, step) {
|
|
346
|
+
const stats = readStats(root, slug)[String(step)] ?? {};
|
|
347
|
+
const anchor = lastLedgerSha(readOr(checkpointsPath(root, slug)));
|
|
348
|
+
return {
|
|
349
|
+
driftWarnings: stats.driftWarnings ?? 0,
|
|
350
|
+
redChecks: stats.redChecks ?? 0,
|
|
351
|
+
reverts: stats.reverts ?? 0,
|
|
352
|
+
outOfBand: anchor === null ? 0 : commitsSince(root, anchor, PLAN_COMMIT_MARKER),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The last `plumbbob check` run's `.check/summary.json`, or null when it is
|
|
357
|
+
* absent or unreadable (nothing to measure with; the fold then falls back to
|
|
358
|
+
* the readout's attested row, if the model wrote one).
|
|
359
|
+
*
|
|
360
|
+
* Read once: the check row is measured from it, and so is the gate's wall
|
|
361
|
+
* clock in the `spent` row.
|
|
362
|
+
*/
|
|
363
|
+
function readCheckSummary(root) {
|
|
364
|
+
try {
|
|
365
|
+
return JSON.parse(readFileSync(join(root, '.check', 'summary.json'), 'utf8'));
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The Your Call block: the moves a human actually makes at a pause, each
|
|
373
|
+
* quoting what they say and stating what happens next.
|
|
374
|
+
*
|
|
375
|
+
* Nobody types "needs work", so the block names what really happens instead:
|
|
376
|
+
* a message that asks is an expand (nothing changes), and a message that
|
|
377
|
+
* directs is the fix. `revert` comes last, because a destructive move is named
|
|
378
|
+
* rather than discovered. `looks good` renders only while the measured check is
|
|
379
|
+
* green; offering a move that would refuse teaches a false ceremony.
|
|
380
|
+
*/
|
|
381
|
+
function yourCallBlock(step, checkGreen) {
|
|
382
|
+
const moves = [];
|
|
383
|
+
if (checkGreen) {
|
|
384
|
+
moves.push(callLine('`looks good`', `I checkpoint Step ${step}; back to the boundary`));
|
|
385
|
+
}
|
|
386
|
+
moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes'));
|
|
387
|
+
moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
|
|
388
|
+
moves.push(callLine('`revert`', 'I wind the work back to the last checkpoint'));
|
|
389
|
+
return ['**Your Call**:', '', ...moves].join('\n');
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* One Your Call row: a list item naming the move, then the outcome clause
|
|
393
|
+
* behind the arrow, opening with a capital letter. The move stays lowercase
|
|
394
|
+
* because it quotes what the human says.
|
|
395
|
+
*/
|
|
396
|
+
function callLine(move, outcome) {
|
|
397
|
+
return `- ${move} → ${outcome}`;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The plan pause's Your Call block: the same shape with the moves that apply
|
|
401
|
+
* there. Nothing is recorded yet, so `revert` has nothing to wind back to and
|
|
402
|
+
* vanishes; `looks good` names the step the plan starts at, which is Step 1 at
|
|
403
|
+
* the plan pause and the first undone step after a mid-build refine.
|
|
404
|
+
*/
|
|
405
|
+
function planCallBlock(first) {
|
|
406
|
+
const starts = first === undefined ? '' : `; /plumbbob:build starts Step ${first.n}`;
|
|
407
|
+
return [
|
|
408
|
+
'**Your Call**:',
|
|
409
|
+
'',
|
|
410
|
+
callLine('`looks good`', `I mark the plan decided${starts}`),
|
|
411
|
+
callLine('`expand`, or any question', 'I show more of what is there; nothing changes'),
|
|
412
|
+
callLine('anything that reads as direction', 'I take it as what to sharpen; the plan is cheap to change now'),
|
|
413
|
+
].join('\n');
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The driver turn's pointer: back to the step still in flight, since a park or
|
|
417
|
+
* a spike interrupts a step without ending it. No model clause rides here; the
|
|
418
|
+
* step is already being built, so the `/model` call is behind us. Null when no
|
|
419
|
+
* step is open, which is the caller's cue to fall back to the forward pointer.
|
|
420
|
+
*/
|
|
421
|
+
function driverNextUpLine(steps, inFlight, total) {
|
|
422
|
+
if (inFlight === null) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const open = steps.find((s) => s.n === inFlight);
|
|
426
|
+
const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
|
|
427
|
+
// The progress count rides every tier's pointer, but only where the plan
|
|
428
|
+
// actually holds the step: "step 9 of 3" would be worse than no count.
|
|
429
|
+
const progress = open === undefined ? '' : ` of ${total}`;
|
|
430
|
+
return `**Next Up**: Back to Step ${inFlight}${progress}${title}`;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The driver pointer while a spike is open: the spike outranks the step it
|
|
434
|
+
* interrupted, so the move named is closing it and the step to come back to
|
|
435
|
+
* rides as a trailing clause.
|
|
436
|
+
*
|
|
437
|
+
* No progress count rides that clause. The count belongs to the step a pointer
|
|
438
|
+
* aims at, and this one aims at the spike; with nothing in flight (a spike
|
|
439
|
+
* opened at the boundary) the clause vanishes and the move stands alone.
|
|
440
|
+
*/
|
|
441
|
+
function spikeNextUpLine(inFlight) {
|
|
442
|
+
const back = inFlight === null ? '' : `, then back to Step ${inFlight}`;
|
|
443
|
+
return `**Next Up**: Close the spike - /plumbbob:spike done${back}`;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The forward pointer: the next undone step with the progress count, its
|
|
447
|
+
* title, and a closing bracket carrying its advisory `- model:` recommendation
|
|
448
|
+
* and where to read the step in full. No next step ⇒ the finish/step nudge
|
|
449
|
+
* instead.
|
|
450
|
+
*
|
|
451
|
+
* The model is the second bold token the line spends, because it is the one
|
|
452
|
+
* the human acts on (a `/model` call) before the next run; the path is a bare
|
|
453
|
+
* `path:line` in a code span, the one link form that opens in a host and still
|
|
454
|
+
* reads as a path in a PR diff.
|
|
455
|
+
*/
|
|
456
|
+
function nextUpLine(nextUp, total, where) {
|
|
457
|
+
if (nextUp === undefined) {
|
|
458
|
+
return '**Next Up**: Nothing planned - /plumbbob:step or /plumbbob:finish';
|
|
459
|
+
}
|
|
460
|
+
const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
|
|
461
|
+
const token = modelToken(nextUp.model);
|
|
462
|
+
const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
|
|
463
|
+
bracket.push(`details: \`${where}:${nextUp.line}\``);
|
|
464
|
+
return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
|
|
102
465
|
}
|
|
103
466
|
/**
|
|
104
467
|
* The model token from a `- model:` recommendation: the first word, so a
|
|
105
|
-
* `model: opus` line followed by an em dash and rationale yields `opus
|
|
106
|
-
* the `/model` call.
|
|
468
|
+
* `model: opus` line followed by an em dash and rationale yields `opus`.
|
|
107
469
|
*
|
|
108
470
|
* Null when there is no recommendation, or it degraded to whitespace (folding
|
|
109
471
|
* the null model into the same guard keeps both branches live).
|
|
@@ -112,6 +474,13 @@ function modelToken(model) {
|
|
|
112
474
|
const first = model?.trim().split(/\s+/)[0];
|
|
113
475
|
return first !== undefined && first.length > 0 ? first : null;
|
|
114
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Capitalize a token's first letter for the card's title-case furniture
|
|
479
|
+
* (`(model: Sonnet)`); the rest rides through unchanged.
|
|
480
|
+
*/
|
|
481
|
+
function capitalize(s) {
|
|
482
|
+
return s.length === 0 ? s : `${s[0]?.toUpperCase()}${s.slice(1)}`;
|
|
483
|
+
}
|
|
115
484
|
/**
|
|
116
485
|
* Read a file as UTF-8, or return '' when it is missing or unreadable: an
|
|
117
486
|
* absent sidecar file reads as empty rather than throwing.
|
|
@@ -125,11 +494,11 @@ function readOr(path) {
|
|
|
125
494
|
}
|
|
126
495
|
}
|
|
127
496
|
/**
|
|
128
|
-
* The in
|
|
129
|
-
* the
|
|
130
|
-
*
|
|
497
|
+
* The count in a one-line marker file: STEP (which step is open) and TICK (the
|
|
498
|
+
* turn the step was entered on) are both written this way. Null when the file
|
|
499
|
+
* is absent or holds anything but a bare number.
|
|
131
500
|
*/
|
|
132
|
-
function
|
|
501
|
+
function readMarker(path) {
|
|
133
502
|
try {
|
|
134
503
|
const raw = readFileSync(path, 'utf8').trim();
|
|
135
504
|
return /^\d+$/.test(raw) ? Number(raw) : null;
|
package/dist/verbs/park.js
CHANGED
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
// build-log.md (the build's human-facing ledger). Capture stays dumb by design:
|
|
4
4
|
// a grep-readable append, no markdown parsing, no model turn (wording the line
|
|
5
5
|
// well is /plumbbob:park's job); triage waits for a step boundary, via harvest.
|
|
6
|
+
// The capture prints its whole ending, the line and the pointer back at the
|
|
7
|
+
// step it interrupted, so the relay is one command.
|
|
6
8
|
// Capture is not a state transition, so it runs in any context (terminal or
|
|
7
9
|
// in-session).
|
|
8
10
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
9
11
|
import { findRepoRoot } from "../lib/git.js";
|
|
10
|
-
import { hasSession, buildLogPath } from "../lib/sidecar.js";
|
|
12
|
+
import { activeBuild, hasSession, buildLogPath } from "../lib/sidecar.js";
|
|
11
13
|
import { appendToSection } from "../lib/buildlog.js";
|
|
14
|
+
import { ending, notice, transition } from "../lib/notice.js";
|
|
15
|
+
import { driverPointer } from "./handoff.js";
|
|
12
16
|
/**
|
|
13
17
|
* Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
|
|
14
18
|
*
|
|
@@ -19,7 +23,7 @@ import { appendToSection } from "../lib/buildlog.js";
|
|
|
19
23
|
export function park(cwd, args) {
|
|
20
24
|
const root = findRepoRoot(cwd);
|
|
21
25
|
if (root === null || !hasSession(root)) {
|
|
22
|
-
process.stderr.write(
|
|
26
|
+
process.stderr.write(notice({ fact: 'no active session', detail: ['nothing to park to'], remedy: 'plumbbob start "<title>"' }));
|
|
23
27
|
return 1;
|
|
24
28
|
}
|
|
25
29
|
const text = args
|
|
@@ -27,16 +31,18 @@ export function park(cwd, args) {
|
|
|
27
31
|
.join(' ')
|
|
28
32
|
.trim();
|
|
29
33
|
if (text.length === 0) {
|
|
30
|
-
process.stderr.write(
|
|
34
|
+
process.stderr.write(notice({ fact: 'park needs text', remedy: 'plumbbob park "the idea you do not want to chase right now"' }));
|
|
31
35
|
return 1;
|
|
32
36
|
}
|
|
33
37
|
const path = buildLogPath(root);
|
|
34
38
|
const updated = appendToSection(readFileSync(path, 'utf8'), 'Park list', `- [ ] ${text}`);
|
|
35
39
|
if (updated === null) {
|
|
36
|
-
process.stderr.write(
|
|
40
|
+
process.stderr.write(notice({ fact: 'no "## Park list" section in build-log.md', remedy: 'add the section, then park again' }));
|
|
37
41
|
return 1;
|
|
38
42
|
}
|
|
39
43
|
writeFileSync(path, updated);
|
|
40
|
-
|
|
44
|
+
// A park interrupts a step without ending it, so the ending is the capture
|
|
45
|
+
// and the pointer back at what was interrupted.
|
|
46
|
+
process.stdout.write(ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }));
|
|
41
47
|
return 0;
|
|
42
48
|
}
|