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/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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// DESIGN boundary.
|
|
12
12
|
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
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";
|
|
14
|
+
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
|
|
15
15
|
import { runCheck } from "../lib/check.js";
|
|
16
16
|
import { readCommitBody } from "../lib/commitbody.js";
|
|
17
17
|
import { checkLatch } from "../lib/latch.js";
|
|
@@ -20,6 +20,8 @@ import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
|
|
|
20
20
|
import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
|
|
21
21
|
import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
|
|
22
22
|
import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
23
|
+
import { advisory, ending, notice, transition } from "../lib/notice.js";
|
|
24
|
+
import { boundaryEnding } from "./handoff.js";
|
|
23
25
|
/**
|
|
24
26
|
* Land a step: latch, check gate, commit, record, return to the boundary.
|
|
25
27
|
*
|
|
@@ -29,7 +31,7 @@ import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
|
29
31
|
export async function checkpoint(cwd, args) {
|
|
30
32
|
const root = findRepoRoot(cwd);
|
|
31
33
|
if (root === null || !hasSession(root)) {
|
|
32
|
-
process.stderr.write(
|
|
34
|
+
process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
|
|
33
35
|
return 1;
|
|
34
36
|
}
|
|
35
37
|
// Before either staging path: `-A` below, and the plan commit's folder-scoped
|
|
@@ -41,7 +43,7 @@ export async function checkpoint(cwd, args) {
|
|
|
41
43
|
}
|
|
42
44
|
const step = resolveStep(root, args);
|
|
43
45
|
if (step === null) {
|
|
44
|
-
process.stderr.write(
|
|
46
|
+
process.stderr.write(notice({ fact: 'no step to checkpoint', remedy: 'pass a step number, or plan a step in intent.md first' }));
|
|
45
47
|
return 1;
|
|
46
48
|
}
|
|
47
49
|
// Checked before the latch and the check gate: a socket stdin can never
|
|
@@ -70,22 +72,32 @@ export async function checkpoint(cwd, args) {
|
|
|
70
72
|
bumpStepStat(root, undefined, step, 'redChecks');
|
|
71
73
|
}
|
|
72
74
|
process.stderr.write(gate === 2
|
|
73
|
-
?
|
|
74
|
-
|
|
75
|
+
? notice({
|
|
76
|
+
fact: 'checkpoint refused',
|
|
77
|
+
detail: ['the check gate itself broke'],
|
|
78
|
+
remedy: 'fix the harness, then run it again',
|
|
79
|
+
})
|
|
80
|
+
: notice({ fact: 'checkpoint refused', detail: ['the check is red'], remedy: 'fix it, then run it again' }));
|
|
75
81
|
return 1;
|
|
76
82
|
}
|
|
77
83
|
let sha;
|
|
84
|
+
// The drift advisory is read off the index (staged, before the commit clears
|
|
85
|
+
// it) but printed inside the ending below, where the fixed order puts it: the
|
|
86
|
+
// fact, the Verdict it earned a rung on, then what qualifies it.
|
|
87
|
+
let drift = '';
|
|
78
88
|
if (isDirty(root)) {
|
|
79
89
|
stageAll(root);
|
|
80
|
-
|
|
81
|
-
const
|
|
90
|
+
drift = scopeDriftNotice(root, step);
|
|
91
|
+
const lead = bodyResult.body ?? fallbackBody(root, step);
|
|
92
|
+
const body = withMarker(`plumbbob step ${step}`, foldDetail(lead, readDetail(root)));
|
|
82
93
|
sha = commit(root, messageArg(args) ?? subjectForStep(root, step), body);
|
|
94
|
+
clearDetail(root); // the detail is folded into the commit now; clear it so no stale detail rides into the next step.
|
|
83
95
|
}
|
|
84
96
|
else {
|
|
85
97
|
sha = headSha(root);
|
|
86
98
|
}
|
|
87
99
|
appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`);
|
|
88
|
-
flipIntent(root, step);
|
|
100
|
+
const flipNotice = flipIntent(root, step);
|
|
89
101
|
stampStepStat(root, undefined, step, 'landedAt', new Date().toISOString());
|
|
90
102
|
logCheckpoint(root, step, sha);
|
|
91
103
|
// The step landed: the build-log's ☐/☑ mirror flips and its Current-step line
|
|
@@ -97,7 +109,17 @@ export async function checkpoint(cwd, args) {
|
|
|
97
109
|
rmSync(stepPath(root), { force: true });
|
|
98
110
|
clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
|
|
99
111
|
clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
|
|
100
|
-
|
|
112
|
+
// The whole ending, one block: the lead line, the Verdict folded over what
|
|
113
|
+
// just landed, the advisories this checkpoint alone can state, and the
|
|
114
|
+
// pointer. Read after the flip and the ledger append, so the fold and the
|
|
115
|
+
// pointer both see the boundary the human is standing at.
|
|
116
|
+
const landed = boundaryEnding(root, activeBuild(root), step);
|
|
117
|
+
process.stdout.write(ending({
|
|
118
|
+
lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: [sha.slice(0, 9)] }),
|
|
119
|
+
verdict: landed.verdict,
|
|
120
|
+
advisories: [drift, flipNotice],
|
|
121
|
+
pointer: landed.pointer,
|
|
122
|
+
}));
|
|
101
123
|
return 0;
|
|
102
124
|
}
|
|
103
125
|
/**
|
|
@@ -137,9 +159,21 @@ function checkpointPlan(root, args) {
|
|
|
137
159
|
// Landing the plan consumes `start`'s entry stamp: a later hand-built diff
|
|
138
160
|
// (no `build <n>`) must find no stale TICK and stay guidance-governed.
|
|
139
161
|
clearTick(root);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
162
|
+
// No Verdict: the plan commit measures nothing. The pointer aims at the step
|
|
163
|
+
// the build starts on, which is what `handoff` renders from a null step.
|
|
164
|
+
process.stdout.write(ending({
|
|
165
|
+
lead: transition({ label: 'Plan', fact: 'committed', detail: [sha.slice(0, 9)] }),
|
|
166
|
+
advisories: staged
|
|
167
|
+
? []
|
|
168
|
+
: [
|
|
169
|
+
advisory({
|
|
170
|
+
fact: 'the plan rides the commit message',
|
|
171
|
+
detail: ['record-only', '.plumbbob/ is gitignored, so the files stay untracked'],
|
|
172
|
+
remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
|
|
173
|
+
}),
|
|
174
|
+
],
|
|
175
|
+
pointer: boundaryEnding(root, activeBuild(root), null).pointer,
|
|
176
|
+
}));
|
|
143
177
|
return 0;
|
|
144
178
|
}
|
|
145
179
|
/**
|
|
@@ -176,23 +210,28 @@ function resolveStep(root, args) {
|
|
|
176
210
|
}
|
|
177
211
|
}
|
|
178
212
|
/**
|
|
179
|
-
*
|
|
213
|
+
* The advisory (never a refusal) for a staged tree that reaches beyond the
|
|
214
|
+
* step's seam, or '' when it held.
|
|
180
215
|
*
|
|
181
|
-
* Guidance, not a gate: the checkpoint captures the drift and says so
|
|
216
|
+
* Guidance, not a gate: the checkpoint captures the drift and says so, and the
|
|
217
|
+
* bumped stat puts a `staged outside the seam` rung under the Verdict. The seam
|
|
182
218
|
* (the step's edit grant: exact paths or `dir/` prefixes) comes from the
|
|
183
219
|
* in-flight SEAM file when a build is live, else the step's declared seam in
|
|
184
220
|
* intent.md. Plumbbob's own artifact plane is whitelisted inside `scopeDrift`,
|
|
185
221
|
* so the `[x]` flip and build-log line this very checkpoint stages never read
|
|
186
222
|
* as drift. No seam means no warning.
|
|
187
223
|
*/
|
|
188
|
-
function
|
|
224
|
+
function scopeDriftNotice(root, step) {
|
|
189
225
|
const seam = seamTokens(root, step);
|
|
190
226
|
const outside = scopeDrift(stagedPaths(root), seam);
|
|
191
|
-
if (outside.length
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
227
|
+
if (outside.length === 0)
|
|
228
|
+
return '';
|
|
229
|
+
bumpStepStat(root, undefined, step, 'driftWarnings'); // accrues into the build-log's stats receipt, and into the Verdict's third rung
|
|
230
|
+
return advisory({
|
|
231
|
+
fact: `staged paths reach outside Step ${step}'s seam`,
|
|
232
|
+
detail: outside,
|
|
233
|
+
remedy: 'the checkpoint captures them, so revise the plan with /plumbbob:step if that is real scope drift',
|
|
234
|
+
});
|
|
196
235
|
}
|
|
197
236
|
/**
|
|
198
237
|
* The seam tokens governing the in-flight step.
|
|
@@ -222,15 +261,20 @@ function seamTokens(root, step) {
|
|
|
222
261
|
*
|
|
223
262
|
* Best-effort bookkeeping (the checkpoint SHA is the source of truth) but the
|
|
224
263
|
* dashboard reads intent.md, so a swallowed failure here would make orientation
|
|
225
|
-
* lie; the catch
|
|
264
|
+
* lie; the catch returns the advisory asking for a hand flip, which the caller
|
|
265
|
+
* folds into the ending. Returns '' on the happy path.
|
|
226
266
|
*/
|
|
227
267
|
function flipIntent(root, step) {
|
|
228
268
|
try {
|
|
229
269
|
writeFileSync(intentPath(root), markStepDone(readFileSync(intentPath(root), 'utf8'), step));
|
|
270
|
+
return '';
|
|
230
271
|
}
|
|
231
272
|
catch {
|
|
232
|
-
|
|
233
|
-
`
|
|
273
|
+
return advisory({
|
|
274
|
+
fact: `could not flip Step ${step} to [x] in intent.md`,
|
|
275
|
+
detail: ['the checkpoint is recorded', `the dashboard still shows Step ${step} as next`],
|
|
276
|
+
remedy: 'flip the checkbox by hand',
|
|
277
|
+
});
|
|
234
278
|
}
|
|
235
279
|
}
|
|
236
280
|
/**
|
|
@@ -359,6 +403,35 @@ function fallbackBody(root, step) {
|
|
|
359
403
|
}
|
|
360
404
|
return parts.length > 0 ? parts.join('\n\n') : undefined;
|
|
361
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* The in-flight step's detail from `.plumbbob/detail.md`, or null when the file
|
|
408
|
+
* is absent or empty.
|
|
409
|
+
*
|
|
410
|
+
* The model overwrites it before every pause; checkpoint reads it once here for
|
|
411
|
+
* the fold, and the caller clears it afterward. A missing or blank file folds
|
|
412
|
+
* nothing.
|
|
413
|
+
*/
|
|
414
|
+
function readDetail(root) {
|
|
415
|
+
try {
|
|
416
|
+
const raw = readFileSync(detailPath(root), 'utf8').trim();
|
|
417
|
+
return raw.length > 0 ? raw : null;
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Fold the detail beneath the lead prose for the commit body.
|
|
425
|
+
*
|
|
426
|
+
* The lead (an explicit `--body`, else the deterministic fallback) leads and the
|
|
427
|
+
* detail file's content follows, joined by a blank line so git keeps them as
|
|
428
|
+
* separate paragraphs. Either part may be absent; an all-empty fold returns
|
|
429
|
+
* undefined so `withMarker` leaves just the marker line.
|
|
430
|
+
*/
|
|
431
|
+
function foldDetail(lead, detail) {
|
|
432
|
+
const parts = [lead, detail].filter((part) => part !== undefined && part !== null && part.length > 0);
|
|
433
|
+
return parts.length > 0 ? parts.join('\n\n') : undefined;
|
|
434
|
+
}
|
|
362
435
|
/**
|
|
363
436
|
* The step's done-when line from intent.md, or null when absent or unreadable.
|
|
364
437
|
*/
|
package/dist/verbs/finish.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// offers the artifact, it does not wall the door. The git footprint stays
|
|
8
8
|
// additive: one forward commit under the Conventional `chore(<scope>): finish`
|
|
9
9
|
// subject, with the `plumbbob finish` identifier riding a marker line at the head
|
|
10
|
-
// of the body so `git log --grep plumbbob` still finds it.
|
|
10
|
+
// of the body so `git log --grep plumbbob` still finds it. The turn's forward
|
|
11
|
+
// pointer is printed here rather than by `handoff`, the one ending handoff
|
|
12
|
+
// cannot render: finish has just cleared the session it would read from.
|
|
11
13
|
import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
12
14
|
import { join } from 'node:path';
|
|
13
15
|
import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
|
|
@@ -15,6 +17,11 @@ import { readCommitBody } from "../lib/commitbody.js";
|
|
|
15
17
|
import { buildScope, checkpointsPath, clearTick, hasSession, intentPath, readStats, refreshExcludes, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
|
|
16
18
|
import { conventionalSubject, withMarker } from "../lib/commitmsg.js";
|
|
17
19
|
import { parseBuildScope } from "../lib/intent.js";
|
|
20
|
+
import { advisory, ending, notice, transition } from "../lib/notice.js";
|
|
21
|
+
// The pointer finish leaves the turn on. Every other one is `handoff`'s, but a
|
|
22
|
+
// finished session has no state left to render from, and past a closed build
|
|
23
|
+
// the only move is framing the next goal.
|
|
24
|
+
const NEXT_UP = '**Next Up**: Nothing planned - /plumbbob:plan';
|
|
18
25
|
/**
|
|
19
26
|
* Close out the active build: report tail, final commit, control-state cleanup.
|
|
20
27
|
*
|
|
@@ -26,7 +33,7 @@ import { parseBuildScope } from "../lib/intent.js";
|
|
|
26
33
|
export function finish(cwd, args = []) {
|
|
27
34
|
const root = findRepoRoot(cwd);
|
|
28
35
|
if (root === null || !hasSession(root)) {
|
|
29
|
-
process.stderr.write(
|
|
36
|
+
process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
|
|
30
37
|
return 1;
|
|
31
38
|
}
|
|
32
39
|
// Checked before any of finish's writes: appendCheckpointShas/appendStats
|
|
@@ -39,14 +46,11 @@ export function finish(cwd, args = []) {
|
|
|
39
46
|
return 1;
|
|
40
47
|
}
|
|
41
48
|
const { build: slug } = resolveBuild(root, args);
|
|
42
|
-
|
|
49
|
+
const reported = existsSync(reportPath(root, slug));
|
|
50
|
+
if (reported) {
|
|
43
51
|
appendCheckpointShas(root, slug);
|
|
44
52
|
appendStats(root, slug);
|
|
45
53
|
}
|
|
46
|
-
else {
|
|
47
|
-
process.stderr.write('plumbbob: note — no report.md found; finishing without one ' +
|
|
48
|
-
'(/plumbbob:finish normally writes the report first). No gate — D9 (finish-no-gate).\n');
|
|
49
|
-
}
|
|
50
54
|
// The final commit: stage the report just written plus the build folder's tail
|
|
51
55
|
// (a step's commit sweeps its own bookkeeping along with the work, so the last
|
|
52
56
|
// step's `checkpoints` line lands one commit late and finish absorbs it) and
|
|
@@ -74,9 +78,29 @@ export function finish(cwd, args = []) {
|
|
|
74
78
|
clearTick(root, slug);
|
|
75
79
|
setGrant(root, null);
|
|
76
80
|
rmSync(join(sidecarDir(root), 'STATE'), { force: true });
|
|
81
|
+
// The whole ending in the fixed order: the lead line, the advisory that
|
|
82
|
+
// qualifies it (finishing without a report is guidance, never a gate; the
|
|
83
|
+
// session is already closed by the time it prints), and the pointer, which is
|
|
84
|
+
// the turn's last text. finish renders that pointer itself: it just cleared
|
|
85
|
+
// the session `handoff` would read one from.
|
|
77
86
|
const where = slug === null ? '.plumbbob/' : `.plumbbob/builds/${slug}/`;
|
|
78
|
-
process.stdout.write(
|
|
79
|
-
|
|
87
|
+
process.stdout.write(ending({
|
|
88
|
+
lead: transition({
|
|
89
|
+
label: 'Session',
|
|
90
|
+
fact: 'finished',
|
|
91
|
+
detail: [sha.slice(0, 9), `${where} rides your branch into the PR`],
|
|
92
|
+
}),
|
|
93
|
+
advisories: reported
|
|
94
|
+
? []
|
|
95
|
+
: [
|
|
96
|
+
advisory({
|
|
97
|
+
fact: 'no report.md found',
|
|
98
|
+
detail: ['finished without one', 'no gate here by design'],
|
|
99
|
+
remedy: '/plumbbob:finish normally writes the report first',
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
pointer: NEXT_UP,
|
|
103
|
+
}));
|
|
80
104
|
return 0;
|
|
81
105
|
}
|
|
82
106
|
/**
|