plumbbob 0.10.3 → 0.11.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 +108 -51
- package/dist/cli-core.js +8 -4
- package/dist/lib/agents.js +2 -2
- package/dist/lib/buildlog.js +22 -0
- package/dist/lib/check.js +76 -6
- package/dist/lib/git.js +60 -5
- package/dist/lib/notice.js +140 -0
- package/dist/lib/orient.js +467 -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 +130 -32
- package/dist/verbs/finish.js +33 -9
- package/dist/verbs/handoff.js +506 -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 +117 -35
- package/skills/finish/SKILL.md +19 -8
- package/skills/park/SKILL.md +15 -1
- package/skills/plan/SKILL.md +59 -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/templates/build-log.md +4 -3
package/dist/verbs/agent.js
CHANGED
|
@@ -20,6 +20,9 @@ import { SLOTS, composeStepContext, formatAgentList, isSlot, listAgents, parseSl
|
|
|
20
20
|
import { appendToSection } from "../lib/buildlog.js";
|
|
21
21
|
import { resolveBoolean, resolveNumber, resolveRecord } from "../lib/settings.js";
|
|
22
22
|
import { appendHandoff, buildFolder, buildLogPath, hasSession, intentPath, resolveBuild, stepPath } from "../lib/sidecar.js";
|
|
23
|
+
import { notice } from "../lib/notice.js";
|
|
24
|
+
/** The remedy on an ambient binding's advisory: a binding is configuration the loop must survive without. */
|
|
25
|
+
const SKIPPING = 'skipping this binding, since the loop works without it';
|
|
23
26
|
/**
|
|
24
27
|
* Dispatch `plumbbob agent list|run`, refusing an unknown subcommand with a hint.
|
|
25
28
|
*/
|
|
@@ -30,7 +33,9 @@ export async function agent(cwd, args = []) {
|
|
|
30
33
|
if (sub === 'run')
|
|
31
34
|
return run(cwd, rest);
|
|
32
35
|
const known = 'Available subcommands: list, run.';
|
|
33
|
-
const message = sub === undefined
|
|
36
|
+
const message = sub === undefined
|
|
37
|
+
? `plumbbob agent <subcommand>. ${known}`
|
|
38
|
+
: notice({ fact: `unknown 'agent' subcommand '${sub}'`, remedy: 'plumbbob agent list, or plumbbob agent run' }).trimEnd();
|
|
34
39
|
process.stderr.write(`${message}\n`);
|
|
35
40
|
return 1;
|
|
36
41
|
}
|
|
@@ -40,7 +45,7 @@ export async function agent(cwd, args = []) {
|
|
|
40
45
|
function list(cwd, _args) {
|
|
41
46
|
const root = findRepoRoot(cwd);
|
|
42
47
|
if (root === null) {
|
|
43
|
-
process.stderr.write(
|
|
48
|
+
process.stderr.write(notice({ fact: 'not inside a git repository' }));
|
|
44
49
|
return 1;
|
|
45
50
|
}
|
|
46
51
|
process.stdout.write(`${formatAgentList(listAgents(root))}\n`);
|
|
@@ -58,7 +63,7 @@ function list(cwd, _args) {
|
|
|
58
63
|
async function run(cwd, args) {
|
|
59
64
|
const root = findRepoRoot(cwd);
|
|
60
65
|
if (root === null || !hasSession(root)) {
|
|
61
|
-
process.stderr.write(
|
|
66
|
+
process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
|
|
62
67
|
return 1;
|
|
63
68
|
}
|
|
64
69
|
const { build: slug, rest } = resolveBuild(root, args);
|
|
@@ -69,13 +74,17 @@ async function run(cwd, args) {
|
|
|
69
74
|
}
|
|
70
75
|
const step = parsed.step ?? readStep(root, slug);
|
|
71
76
|
if (step === null) {
|
|
72
|
-
process.stderr.write(
|
|
77
|
+
process.stderr.write(notice({ fact: 'no step to run against', remedy: 'pass --step N, or plumbbob build N first' }));
|
|
73
78
|
return 1;
|
|
74
79
|
}
|
|
75
80
|
// A `--agent <path>` flag still needs a name; it labels an explicit run (the
|
|
76
81
|
// handoff ledger and the human summary key on the name), it does not name one.
|
|
77
82
|
if (parsed.name === undefined && parsed.flagPath !== undefined) {
|
|
78
|
-
process.stderr.write(
|
|
83
|
+
process.stderr.write(notice({
|
|
84
|
+
fact: '--agent needs an agent name too',
|
|
85
|
+
detail: ['the name is the run label'],
|
|
86
|
+
remedy: 'plumbbob agent run reviewer --agent ./path --step 3',
|
|
87
|
+
}));
|
|
79
88
|
return 1;
|
|
80
89
|
}
|
|
81
90
|
// An explicit name outranks every binding: run exactly it, fail loud on a
|
|
@@ -103,12 +112,13 @@ async function run(cwd, args) {
|
|
|
103
112
|
async function runOne(root, slug, step, spec) {
|
|
104
113
|
const resolution = resolveAgent(root, spec.name, spec.flagPath !== undefined ? { flagPath: spec.flagPath } : {});
|
|
105
114
|
if (!resolution.ok) {
|
|
106
|
-
return degrade(spec.ambient,
|
|
115
|
+
return degrade(spec.ambient, notice({ fact: resolution.error }).trimEnd(), notice({ fact: resolution.error, advisory: true, remedy: SKIPPING }).trimEnd());
|
|
107
116
|
}
|
|
108
117
|
const { manifest, dir } = resolution.agent;
|
|
109
118
|
const resolved = resolveMode(spec.mode, manifest);
|
|
110
119
|
if (!resolved.ok) {
|
|
111
|
-
|
|
120
|
+
const parts = { fact: resolved.fact, detail: resolved.detail };
|
|
121
|
+
return degrade(spec.ambient, notice(resolved.remedy === undefined ? parts : { ...parts, remedy: resolved.remedy }).trimEnd(), notice({ ...parts, advisory: true, remedy: SKIPPING }).trimEnd());
|
|
112
122
|
}
|
|
113
123
|
const mode = resolved.mode;
|
|
114
124
|
let intent;
|
|
@@ -116,7 +126,7 @@ async function runOne(root, slug, step, spec) {
|
|
|
116
126
|
intent = readFileSync(intentPath(root, slug), 'utf8');
|
|
117
127
|
}
|
|
118
128
|
catch {
|
|
119
|
-
process.stderr.write(
|
|
129
|
+
process.stderr.write(notice({ fact: 'no intent.md for the active build', detail: ['cannot compose the agent input'] }));
|
|
120
130
|
return 1;
|
|
121
131
|
}
|
|
122
132
|
const composed = composeStepContext({
|
|
@@ -168,11 +178,14 @@ async function runOne(root, slug, step, spec) {
|
|
|
168
178
|
*/
|
|
169
179
|
async function runBound(root, slug, step, modeFlag) {
|
|
170
180
|
if (modeFlag === undefined) {
|
|
171
|
-
process.stderr.write(
|
|
181
|
+
process.stderr.write(notice({
|
|
182
|
+
fact: 'agent run needs an agent name',
|
|
183
|
+
remedy: "name one, or pass --mode <slot> to run the step's bound agents",
|
|
184
|
+
}));
|
|
172
185
|
return 1;
|
|
173
186
|
}
|
|
174
187
|
if (!isSlot(modeFlag)) {
|
|
175
|
-
process.stderr.write(
|
|
188
|
+
process.stderr.write(notice({ fact: `unknown --mode '${modeFlag}'`, detail: [...SLOTS] }));
|
|
176
189
|
return 1;
|
|
177
190
|
}
|
|
178
191
|
const harness = readHarnessFile(join(buildFolder(root, slug), 'harness.json'));
|
|
@@ -188,7 +201,7 @@ async function runBound(root, slug, step, modeFlag) {
|
|
|
188
201
|
slot: modeFlag,
|
|
189
202
|
});
|
|
190
203
|
if (names.length === 0) {
|
|
191
|
-
process.stderr.write(
|
|
204
|
+
process.stderr.write(notice({ fact: `no agents bound to the '${modeFlag}' slot for step ${step}`, detail: ['nothing to run'] }));
|
|
192
205
|
return 0;
|
|
193
206
|
}
|
|
194
207
|
let worst = 0;
|
|
@@ -229,20 +242,20 @@ function parseRunArgs(args) {
|
|
|
229
242
|
if (arg === '--step') {
|
|
230
243
|
const value = args[++i];
|
|
231
244
|
if (value === undefined || !/^\d+$/.test(value) || Number(value) < 1) {
|
|
232
|
-
return
|
|
245
|
+
return notice({ fact: '--step needs a positive step number' }).trimEnd();
|
|
233
246
|
}
|
|
234
247
|
step = Number(value);
|
|
235
248
|
}
|
|
236
249
|
else if (arg === '--mode') {
|
|
237
250
|
const value = args[++i];
|
|
238
251
|
if (value === undefined)
|
|
239
|
-
return
|
|
252
|
+
return notice({ fact: '--mode needs a slot', detail: [...SLOTS] }).trimEnd();
|
|
240
253
|
mode = value;
|
|
241
254
|
}
|
|
242
255
|
else if (arg === '--agent') {
|
|
243
256
|
const value = args[++i];
|
|
244
257
|
if (value === undefined)
|
|
245
|
-
return
|
|
258
|
+
return notice({ fact: '--agent needs a path to an agent directory' }).trimEnd();
|
|
246
259
|
flagPath = value;
|
|
247
260
|
}
|
|
248
261
|
else if (!arg.startsWith('--')) {
|
|
@@ -262,12 +275,14 @@ function parseRunArgs(args) {
|
|
|
262
275
|
*/
|
|
263
276
|
function resolveMode(flag, manifest) {
|
|
264
277
|
if (flag !== undefined) {
|
|
265
|
-
if (!isSlot(flag))
|
|
266
|
-
return { ok: false,
|
|
278
|
+
if (!isSlot(flag)) {
|
|
279
|
+
return { ok: false, fact: `unknown --mode '${flag}'`, detail: [...SLOTS] };
|
|
280
|
+
}
|
|
267
281
|
if (!manifest.slots.includes(flag)) {
|
|
268
282
|
return {
|
|
269
283
|
ok: false,
|
|
270
|
-
|
|
284
|
+
fact: `agent "${manifest.name}" does not declare the '${flag}' slot`,
|
|
285
|
+
detail: [`it declares ${manifest.slots.join(', ')}`],
|
|
271
286
|
};
|
|
272
287
|
}
|
|
273
288
|
return { ok: true, mode: flag };
|
|
@@ -276,7 +291,9 @@ function resolveMode(flag, manifest) {
|
|
|
276
291
|
return { ok: true, mode: manifest.slots[0] };
|
|
277
292
|
return {
|
|
278
293
|
ok: false,
|
|
279
|
-
|
|
294
|
+
fact: `agent "${manifest.name}" declares multiple slots`,
|
|
295
|
+
detail: manifest.slots,
|
|
296
|
+
remedy: 'pass --mode <slot>',
|
|
280
297
|
};
|
|
281
298
|
}
|
|
282
299
|
/**
|
|
@@ -307,15 +324,18 @@ function report(root, slug, name, mode, step, result) {
|
|
|
307
324
|
function failureLine(name, result) {
|
|
308
325
|
switch (result.reason) {
|
|
309
326
|
case 'exit':
|
|
310
|
-
return
|
|
327
|
+
return notice({
|
|
328
|
+
fact: `agent "${name}" exited ${result.code}`,
|
|
329
|
+
detail: ['a failed run, stopping', 'no side effects applied'],
|
|
330
|
+
}).trimEnd();
|
|
311
331
|
case 'contract':
|
|
312
|
-
return
|
|
332
|
+
return notice({ fact: `agent "${name}" is out of contract`, detail: [result.error] }).trimEnd();
|
|
313
333
|
case 'timeout':
|
|
314
|
-
return
|
|
334
|
+
return notice({ fact: `agent "${name}" timed out after ${result.seconds}s`, detail: ['killed'] }).trimEnd();
|
|
315
335
|
case 'interrupted':
|
|
316
|
-
return
|
|
336
|
+
return notice({ fact: `agent "${name}" killed`, detail: ['interrupted'] }).trimEnd();
|
|
317
337
|
case 'spawn':
|
|
318
|
-
return
|
|
338
|
+
return notice({ fact: `could not spawn agent "${name}"`, detail: [result.error] }).trimEnd();
|
|
319
339
|
}
|
|
320
340
|
}
|
|
321
341
|
/**
|
|
@@ -334,17 +354,20 @@ function applyParked(root, slug, parked) {
|
|
|
334
354
|
content = readFileSync(path, 'utf8');
|
|
335
355
|
}
|
|
336
356
|
catch {
|
|
337
|
-
process.stderr.write(
|
|
357
|
+
process.stderr.write(notice({
|
|
358
|
+
fact: 'no build-log.md',
|
|
359
|
+
detail: [`could not park ${parked.length} line${parked.length === 1 ? '' : 's'} the agent returned`],
|
|
360
|
+
}));
|
|
338
361
|
return;
|
|
339
362
|
}
|
|
340
363
|
for (const line of parked) {
|
|
341
364
|
const updated = appendToSection(content, 'Park list', `- [ ] ${line}`);
|
|
342
365
|
if (updated === null) {
|
|
343
|
-
process.stderr.write(
|
|
366
|
+
process.stderr.write(notice({ fact: 'no "## Park list" in build-log.md', detail: [`could not park ${line}`] }));
|
|
344
367
|
return;
|
|
345
368
|
}
|
|
346
369
|
content = updated;
|
|
347
|
-
process.stderr.write(
|
|
370
|
+
process.stderr.write(notice({ prefix: 'parked', fact: line }));
|
|
348
371
|
}
|
|
349
372
|
writeFileSync(path, content);
|
|
350
373
|
}
|
|
@@ -356,15 +379,24 @@ function applyParked(root, slug, parked) {
|
|
|
356
379
|
* the same status for the calling skill; this stderr copy is the terminal read.
|
|
357
380
|
*/
|
|
358
381
|
function humanSummary(name, mode, envelope) {
|
|
359
|
-
const
|
|
360
|
-
|
|
382
|
+
const detail = [`${mode} slot`, envelope.summary];
|
|
383
|
+
if (envelope.notes.length > 0)
|
|
384
|
+
detail.push(envelope.notes);
|
|
361
385
|
if (envelope.status === 'blocked') {
|
|
362
|
-
return
|
|
386
|
+
return notice({
|
|
387
|
+
fact: `agent "${name}" reports blocked`,
|
|
388
|
+
detail,
|
|
389
|
+
remedy: "the agent couldn't finish, so unblock it and re-run",
|
|
390
|
+
});
|
|
363
391
|
}
|
|
364
392
|
if (envelope.status === 'drift') {
|
|
365
|
-
return
|
|
393
|
+
return notice({
|
|
394
|
+
fact: `agent "${name}" reports drift`,
|
|
395
|
+
detail,
|
|
396
|
+
remedy: 'the plan no longer matches reality, so /plumbbob:refine before continuing',
|
|
397
|
+
});
|
|
366
398
|
}
|
|
367
|
-
return
|
|
399
|
+
return notice({ fact: `agent "${name}" reports ${envelope.status}`, detail });
|
|
368
400
|
}
|
|
369
401
|
/**
|
|
370
402
|
* The in-flight step for this build (the STEP marker `build` wrote), or null.
|
package/dist/verbs/build.js
CHANGED
|
@@ -10,6 +10,7 @@ import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTic
|
|
|
10
10
|
import { parseStepSeam } from "../lib/intent.js";
|
|
11
11
|
import { parseSteps } from "../lib/orient.js";
|
|
12
12
|
import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
13
|
+
import { notice } from "../lib/notice.js";
|
|
13
14
|
/**
|
|
14
15
|
* Enter step n (or the next undone step) and write its SEAM/STEP markers.
|
|
15
16
|
*
|
|
@@ -21,7 +22,7 @@ import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
|
21
22
|
export function build(cwd, args) {
|
|
22
23
|
const root = findRepoRoot(cwd);
|
|
23
24
|
if (root === null || !hasSession(root)) {
|
|
24
|
-
process.stderr.write(
|
|
25
|
+
process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
|
|
25
26
|
return 1;
|
|
26
27
|
}
|
|
27
28
|
const { build: slug, rest } = resolveBuild(root, args);
|
|
@@ -30,13 +31,17 @@ export function build(cwd, args) {
|
|
|
30
31
|
// through the range, then pause), not a CLI capability: the CLI records one
|
|
31
32
|
// in-flight step at a time. Name it rather than bounce off the generic usage.
|
|
32
33
|
if (raw !== undefined && /^\d+-\d*$/.test(raw)) {
|
|
33
|
-
process.stderr.write(
|
|
34
|
+
process.stderr.write(notice({
|
|
35
|
+
fact: 'build takes one step number',
|
|
36
|
+
detail: [`\`${raw}\` is a /plumbbob:build range, which auto-approves through it and then pauses`],
|
|
37
|
+
remedy: `plumbbob build ${raw.split('-')[0]}`,
|
|
38
|
+
}));
|
|
34
39
|
return 1;
|
|
35
40
|
}
|
|
36
41
|
// An explicit arg that isn't a positive integer is a usage error: caught before
|
|
37
42
|
// reading intent.md so the message doesn't depend on the plan being present.
|
|
38
43
|
if (raw !== undefined && (!/^\d+$/.test(raw) || Number(raw) < 1)) {
|
|
39
|
-
process.stderr.write(
|
|
44
|
+
process.stderr.write(notice({ fact: 'build needs a step number', remedy: 'plumbbob build 2' }));
|
|
40
45
|
return 1;
|
|
41
46
|
}
|
|
42
47
|
const intent = readFileSync(intentPath(root, slug), 'utf8');
|
|
@@ -49,7 +54,11 @@ export function build(cwd, args) {
|
|
|
49
54
|
if (raw === undefined) {
|
|
50
55
|
const nextUndone = steps.find((s) => !s.done);
|
|
51
56
|
if (nextUndone === undefined) {
|
|
52
|
-
process.stderr.write(
|
|
57
|
+
process.stderr.write(notice({
|
|
58
|
+
fact: 'no undone step to build',
|
|
59
|
+
detail: ['every planned step is checkpointed'],
|
|
60
|
+
remedy: '/plumbbob:step to add an increment, or /plumbbob:finish',
|
|
61
|
+
}));
|
|
53
62
|
return 1;
|
|
54
63
|
}
|
|
55
64
|
step = nextUndone.n;
|
|
@@ -59,7 +68,7 @@ export function build(cwd, args) {
|
|
|
59
68
|
}
|
|
60
69
|
const parsed = parseStepSeam(intent, step);
|
|
61
70
|
if (!parsed.ok) {
|
|
62
|
-
process.stderr.write(
|
|
71
|
+
process.stderr.write(notice({ fact: parsed.error, remedy: `fix the step's seam in intent.md, then \`build ${step}\` again` }));
|
|
63
72
|
return 1;
|
|
64
73
|
}
|
|
65
74
|
writeFileSync(seamPath(root, slug), `${parsed.seam.join('\n')}\n`);
|
|
@@ -81,10 +90,17 @@ export function build(cwd, args) {
|
|
|
81
90
|
// deliberate `build 22` and a confused one look different on the page.
|
|
82
91
|
const skipped = raw === undefined ? 0 : steps.filter((s) => !s.done && s.n < step).length;
|
|
83
92
|
const picked = raw === undefined
|
|
84
|
-
? '
|
|
93
|
+
? ['next undone']
|
|
85
94
|
: skipped > 0
|
|
86
|
-
?
|
|
87
|
-
:
|
|
88
|
-
|
|
95
|
+
? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
|
|
96
|
+
: [];
|
|
97
|
+
// Two lines, one colon each, then the seam as a plain readout beneath the
|
|
98
|
+
// notice that frames it: the paths are a list, and a list is not a one-liner.
|
|
99
|
+
process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
|
|
100
|
+
notice({
|
|
101
|
+
fact: 'the seam is orientation, not a lock',
|
|
102
|
+
detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
|
|
103
|
+
}) +
|
|
104
|
+
`${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
|
|
89
105
|
return 0;
|
|
90
106
|
}
|
package/dist/verbs/check.js
CHANGED
|
@@ -67,10 +67,15 @@ function slotList(args, flag) {
|
|
|
67
67
|
return names.length > 0 ? { [flag.slice(2)]: names } : {};
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
* The one-line human verdict for a check exit code.
|
|
70
|
+
* The one-line human verdict for a check exit code, printed on its own line.
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
72
|
+
* This is `plumbbob check`'s own console verdict, for whoever ran it. In the
|
|
73
|
+
* turn anatomy the gate verdict's home is the recap's check row, which
|
|
74
|
+
* `plumbbob handoff` re-emits from its own read of `.check/summary.json`
|
|
75
|
+
* (docs/presentation.md); nothing relays these strings any more, but they stay
|
|
76
|
+
* word-for-word stable because humans grep scrollback for them. A broken gate
|
|
77
|
+
* (exit 2) reports distinctly from red: a misconfigured harness must never
|
|
78
|
+
* read as broken code, though both block a checkpoint.
|
|
74
79
|
*/
|
|
75
80
|
function verdictLine(code) {
|
|
76
81
|
if (code === 0)
|
package/dist/verbs/checkpoint.js
CHANGED
|
@@ -10,16 +10,19 @@
|
|
|
10
10
|
// `[x]`, and clears the STEP/SEAM markers: returning the dashboard to the
|
|
11
11
|
// DESIGN boundary.
|
|
12
12
|
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { relative } from 'node:path';
|
|
13
14
|
import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
|
|
14
|
-
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearHandoff, clearTick, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
|
|
15
|
+
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
|
|
15
16
|
import { runCheck } from "../lib/check.js";
|
|
16
17
|
import { readCommitBody } from "../lib/commitbody.js";
|
|
17
18
|
import { checkLatch } from "../lib/latch.js";
|
|
18
19
|
import { markStepDone, parseSteps } from "../lib/orient.js";
|
|
19
20
|
import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
|
|
20
21
|
import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
|
|
21
|
-
import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
|
|
22
|
+
import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
|
|
22
23
|
import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
24
|
+
import { advisory, ending, notice, transition } from "../lib/notice.js";
|
|
25
|
+
import { boundaryEnding, planRecord, stepRecord } from "./handoff.js";
|
|
23
26
|
/**
|
|
24
27
|
* Land a step: latch, check gate, commit, record, return to the boundary.
|
|
25
28
|
*
|
|
@@ -29,7 +32,7 @@ import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
|
29
32
|
export async function checkpoint(cwd, args) {
|
|
30
33
|
const root = findRepoRoot(cwd);
|
|
31
34
|
if (root === null || !hasSession(root)) {
|
|
32
|
-
process.stderr.write(
|
|
35
|
+
process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
|
|
33
36
|
return 1;
|
|
34
37
|
}
|
|
35
38
|
// Before either staging path: `-A` below, and the plan commit's folder-scoped
|
|
@@ -41,7 +44,7 @@ export async function checkpoint(cwd, args) {
|
|
|
41
44
|
}
|
|
42
45
|
const step = resolveStep(root, args);
|
|
43
46
|
if (step === null) {
|
|
44
|
-
process.stderr.write(
|
|
47
|
+
process.stderr.write(notice({ fact: 'no step to checkpoint', remedy: 'pass a step number, or plan a step in intent.md first' }));
|
|
45
48
|
return 1;
|
|
46
49
|
}
|
|
47
50
|
// Checked before the latch and the check gate: a socket stdin can never
|
|
@@ -70,24 +73,40 @@ export async function checkpoint(cwd, args) {
|
|
|
70
73
|
bumpStepStat(root, undefined, step, 'redChecks');
|
|
71
74
|
}
|
|
72
75
|
process.stderr.write(gate === 2
|
|
73
|
-
?
|
|
74
|
-
|
|
76
|
+
? notice({
|
|
77
|
+
fact: 'checkpoint refused',
|
|
78
|
+
detail: ['the check gate itself broke'],
|
|
79
|
+
remedy: 'fix the harness, then run it again',
|
|
80
|
+
})
|
|
81
|
+
: notice({ fact: 'checkpoint refused', detail: ['the check is red'], remedy: 'fix it, then run it again' }));
|
|
75
82
|
return 1;
|
|
76
83
|
}
|
|
84
|
+
// The step's record, as the pause showed it: measured now, before the work
|
|
85
|
+
// is staged, since the seam and diff rows read the working tree the way the
|
|
86
|
+
// pause did. It lands in the build-log's Log beneath the dated line (the
|
|
87
|
+
// tracked ledger is the archive, and it rides the branch where a commit body
|
|
88
|
+
// would not survive the squash); the commit body keeps its marker and the
|
|
89
|
+
// lead prose only.
|
|
90
|
+
const record = stepRecord(root, activeBuild(root), step, readDetail(root) ?? '');
|
|
77
91
|
let sha;
|
|
92
|
+
// The drift advisory is read off the index (staged, before the commit clears
|
|
93
|
+
// it) but printed inside the ending below, where the fixed order puts it: the
|
|
94
|
+
// fact, the Verdict it earned a rung on, then what qualifies it.
|
|
95
|
+
let drift = '';
|
|
78
96
|
if (isDirty(root)) {
|
|
79
97
|
stageAll(root);
|
|
80
|
-
|
|
98
|
+
drift = scopeDriftNotice(root, step);
|
|
81
99
|
const body = withMarker(`plumbbob step ${step}`, bodyResult.body ?? fallbackBody(root, step));
|
|
82
100
|
sha = commit(root, messageArg(args) ?? subjectForStep(root, step), body);
|
|
83
101
|
}
|
|
84
102
|
else {
|
|
85
103
|
sha = headSha(root);
|
|
86
104
|
}
|
|
105
|
+
clearDetail(root); // the detail is recorded in the Log below; clear it so no stale detail rides into the next step.
|
|
87
106
|
appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`);
|
|
88
|
-
flipIntent(root, step);
|
|
107
|
+
const flipNotice = flipIntent(root, step);
|
|
89
108
|
stampStepStat(root, undefined, step, 'landedAt', new Date().toISOString());
|
|
90
|
-
logCheckpoint(root, step, sha);
|
|
109
|
+
const pointer = logCheckpoint(root, step, sha, record);
|
|
91
110
|
// The step landed: the build-log's ☐/☑ mirror flips and its Current-step line
|
|
92
111
|
// returns to the boundary, re-read from the intent.md `flipIntent` just wrote.
|
|
93
112
|
// The artifact-plane whitelist keeps this build-log write from ever reading as
|
|
@@ -97,7 +116,17 @@ export async function checkpoint(cwd, args) {
|
|
|
97
116
|
rmSync(stepPath(root), { force: true });
|
|
98
117
|
clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
|
|
99
118
|
clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
|
|
100
|
-
|
|
119
|
+
// The whole ending, one block: the lead line, the Verdict folded over what
|
|
120
|
+
// just landed, the advisories this checkpoint alone can state, and the
|
|
121
|
+
// pointer. Read after the flip and the ledger append, so the fold and the
|
|
122
|
+
// pointer both see the boundary the human is standing at.
|
|
123
|
+
const landed = boundaryEnding(root, activeBuild(root), step);
|
|
124
|
+
process.stdout.write(ending({
|
|
125
|
+
lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
|
|
126
|
+
verdict: landed.verdict,
|
|
127
|
+
advisories: [drift, flipNotice],
|
|
128
|
+
pointer: landed.pointer,
|
|
129
|
+
}));
|
|
101
130
|
return 0;
|
|
102
131
|
}
|
|
103
132
|
/**
|
|
@@ -134,12 +163,28 @@ function checkpointPlan(root, args) {
|
|
|
134
163
|
const staged = stagePath(root, buildFolder(root));
|
|
135
164
|
const sha = commit(root, planSubject(root), withMarker('plumbbob plan', bodyResult.body ?? undefined));
|
|
136
165
|
appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
|
|
166
|
+
// The cold read that approved the plan is the Log's first entry, beneath a
|
|
167
|
+
// dated plan line; the detail file is then spent, the same as at a step.
|
|
168
|
+
const pointer = logPlan(root, sha, planRecord(readDetail(root) ?? ''));
|
|
169
|
+
clearDetail(root);
|
|
137
170
|
// Landing the plan consumes `start`'s entry stamp: a later hand-built diff
|
|
138
171
|
// (no `build <n>`) must find no stale TICK and stay guidance-governed.
|
|
139
172
|
clearTick(root);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
173
|
+
// No Verdict: the plan commit measures nothing. The pointer aims at the step
|
|
174
|
+
// the build starts on, which is what `handoff` renders from a null step.
|
|
175
|
+
process.stdout.write(ending({
|
|
176
|
+
lead: transition({ label: 'Plan', fact: 'committed', detail: leadDetail(sha, pointer) }),
|
|
177
|
+
advisories: staged
|
|
178
|
+
? []
|
|
179
|
+
: [
|
|
180
|
+
advisory({
|
|
181
|
+
fact: 'the plan rides the commit message',
|
|
182
|
+
detail: ['record-only', '.plumbbob/ is gitignored, so the files stay untracked'],
|
|
183
|
+
remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
|
|
184
|
+
}),
|
|
185
|
+
],
|
|
186
|
+
pointer: boundaryEnding(root, activeBuild(root), null).pointer,
|
|
187
|
+
}));
|
|
143
188
|
return 0;
|
|
144
189
|
}
|
|
145
190
|
/**
|
|
@@ -176,23 +221,28 @@ function resolveStep(root, args) {
|
|
|
176
221
|
}
|
|
177
222
|
}
|
|
178
223
|
/**
|
|
179
|
-
*
|
|
224
|
+
* The advisory (never a refusal) for a staged tree that reaches beyond the
|
|
225
|
+
* step's seam, or '' when it held.
|
|
180
226
|
*
|
|
181
|
-
* Guidance, not a gate: the checkpoint captures the drift and says so
|
|
227
|
+
* Guidance, not a gate: the checkpoint captures the drift and says so, and the
|
|
228
|
+
* bumped stat puts a `staged outside the seam` rung under the Verdict. The seam
|
|
182
229
|
* (the step's edit grant: exact paths or `dir/` prefixes) comes from the
|
|
183
230
|
* in-flight SEAM file when a build is live, else the step's declared seam in
|
|
184
231
|
* intent.md. Plumbbob's own artifact plane is whitelisted inside `scopeDrift`,
|
|
185
232
|
* so the `[x]` flip and build-log line this very checkpoint stages never read
|
|
186
233
|
* as drift. No seam means no warning.
|
|
187
234
|
*/
|
|
188
|
-
function
|
|
235
|
+
function scopeDriftNotice(root, step) {
|
|
189
236
|
const seam = seamTokens(root, step);
|
|
190
237
|
const outside = scopeDrift(stagedPaths(root), seam);
|
|
191
|
-
if (outside.length
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
238
|
+
if (outside.length === 0)
|
|
239
|
+
return '';
|
|
240
|
+
bumpStepStat(root, undefined, step, 'driftWarnings'); // accrues into the build-log's stats receipt, and into the Verdict's third rung
|
|
241
|
+
return advisory({
|
|
242
|
+
fact: `staged paths reach outside Step ${step}'s seam`,
|
|
243
|
+
detail: outside,
|
|
244
|
+
remedy: 'the checkpoint captures them, so revise the plan with /plumbbob:step if that is real scope drift',
|
|
245
|
+
});
|
|
196
246
|
}
|
|
197
247
|
/**
|
|
198
248
|
* The seam tokens governing the in-flight step.
|
|
@@ -222,37 +272,68 @@ function seamTokens(root, step) {
|
|
|
222
272
|
*
|
|
223
273
|
* Best-effort bookkeeping (the checkpoint SHA is the source of truth) but the
|
|
224
274
|
* dashboard reads intent.md, so a swallowed failure here would make orientation
|
|
225
|
-
* lie; the catch
|
|
275
|
+
* lie; the catch returns the advisory asking for a hand flip, which the caller
|
|
276
|
+
* folds into the ending. Returns '' on the happy path.
|
|
226
277
|
*/
|
|
227
278
|
function flipIntent(root, step) {
|
|
228
279
|
try {
|
|
229
280
|
writeFileSync(intentPath(root), markStepDone(readFileSync(intentPath(root), 'utf8'), step));
|
|
281
|
+
return '';
|
|
230
282
|
}
|
|
231
283
|
catch {
|
|
232
|
-
|
|
233
|
-
`
|
|
284
|
+
return advisory({
|
|
285
|
+
fact: `could not flip Step ${step} to [x] in intent.md`,
|
|
286
|
+
detail: ['the checkpoint is recorded', `the dashboard still shows Step ${step} as next`],
|
|
287
|
+
remedy: 'flip the checkbox by hand',
|
|
288
|
+
});
|
|
234
289
|
}
|
|
235
290
|
}
|
|
236
291
|
/**
|
|
237
|
-
*
|
|
292
|
+
* The lead line's trailing parenthetical: the short SHA, and where the Log
|
|
293
|
+
* entry landed when one did, as the same `details:` clause Next Up carries.
|
|
294
|
+
*/
|
|
295
|
+
function leadDetail(sha, pointer) {
|
|
296
|
+
return pointer === null ? [sha.slice(0, 9)] : [sha.slice(0, 9), `details: \`${pointer}\``];
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Append the step's entry to the build-log's `## Log` section: the dated line,
|
|
300
|
+
* and beneath it the record of the pause when the model wrote one.
|
|
238
301
|
*
|
|
239
302
|
* The build's history accrues at each checkpoint instead of being reconstructed
|
|
240
303
|
* at finish; the step's title is lifted from intent.md when still present.
|
|
241
304
|
* Best-effort: a missing or odd build-log never blocks a checkpoint; the
|
|
242
|
-
* `checkpoints` SHA is the source of truth.
|
|
305
|
+
* `checkpoints` SHA is the source of truth. Returns where the line landed, as
|
|
306
|
+
* `path:line` from the repo root, or null when nothing was written.
|
|
307
|
+
*/
|
|
308
|
+
function logCheckpoint(root, step, sha, record) {
|
|
309
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
310
|
+
return appendLog(root, checkpointLogLine(date, step, sha, titleForStep(root, step), statsSuffix(root, step)), record);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Append the plan's entry to the Log: a dated `plan committed` line, and
|
|
314
|
+
* beneath it the cold read that approved the plan when the model wrote one.
|
|
315
|
+
*/
|
|
316
|
+
function logPlan(root, sha, record) {
|
|
317
|
+
return appendLog(root, planLogLine(new Date().toISOString().slice(0, 10), sha), record);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Write one Log entry and say where its dated line landed (`path:line` from
|
|
321
|
+
* the repo root), or null when the section is missing or the write failed.
|
|
243
322
|
*/
|
|
244
|
-
function
|
|
323
|
+
function appendLog(root, line, record) {
|
|
245
324
|
try {
|
|
246
325
|
const path = buildLogPath(root);
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (updated !== null) {
|
|
251
|
-
writeFileSync(path, updated);
|
|
326
|
+
const updated = appendToSection(readFileSync(path, 'utf8'), 'Log', logEntry(line, record));
|
|
327
|
+
if (updated === null) {
|
|
328
|
+
return null;
|
|
252
329
|
}
|
|
330
|
+
writeFileSync(path, updated);
|
|
331
|
+
const at = updated.split('\n').lastIndexOf(line);
|
|
332
|
+
return at === -1 ? null : `${relative(root, path)}:${at + 1}`;
|
|
253
333
|
}
|
|
254
334
|
catch {
|
|
255
335
|
// best-effort ledger; never fail a checkpoint over the build-log.
|
|
336
|
+
return null;
|
|
256
337
|
}
|
|
257
338
|
}
|
|
258
339
|
/**
|
|
@@ -359,6 +440,23 @@ function fallbackBody(root, step) {
|
|
|
359
440
|
}
|
|
360
441
|
return parts.length > 0 ? parts.join('\n\n') : undefined;
|
|
361
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* The in-flight step's detail from `.plumbbob/detail.md`, or null when the file
|
|
445
|
+
* is absent or empty.
|
|
446
|
+
*
|
|
447
|
+
* The model overwrites it before every pause; checkpoint reads it once here for
|
|
448
|
+
* the Log's record, and the caller clears it afterward. A missing or blank file
|
|
449
|
+
* records nothing.
|
|
450
|
+
*/
|
|
451
|
+
function readDetail(root) {
|
|
452
|
+
try {
|
|
453
|
+
const raw = readFileSync(detailPath(root), 'utf8').trim();
|
|
454
|
+
return raw.length > 0 ? raw : null;
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
362
460
|
/**
|
|
363
461
|
* The step's done-when line from intent.md, or null when absent or unreadable.
|
|
364
462
|
*/
|