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.
@@ -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 ? `plumbbob agent <subcommand>. ${known}` : `plumbbob: unknown 'agent' subcommand '${sub}'. ${known}`;
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('plumbbob: not inside a git repository.\n');
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('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
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('plumbbob: no step to run against — pass --step N, or `plumbbob build N` first.\n');
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('plumbbob: --agent needs an agent name too, for the run label. Try: plumbbob agent run reviewer --agent ./path --step 3.\n');
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, `plumbbob: ${resolution.error}`, `plumbbob: bound agent "${spec.name}" did not resolve — ${resolution.error} Skipping — D54 (bindings-degrade-soft): the loop works without it.`);
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
- return degrade(spec.ambient, resolved.error, `plumbbob: bound agent "${spec.name}" — ${resolved.error.replace(/^plumbbob:\s*/, '')} Skipping — D54 (bindings-degrade-soft).`);
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('plumbbob: no intent.md for the active build — cannot compose the agent input.\n');
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('plumbbob: agent run needs an agent name, or --mode <slot> to run the step\'s bound agents.\n');
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(`plumbbob: unknown --mode '${modeFlag}' — slots are ${SLOTS.join(', ')}.\n`);
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(`plumbbob: no agents bound to the '${modeFlag}' slot for step ${step} — nothing to run.\n`);
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 'plumbbob: --step needs a positive step number.';
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 `plumbbob: --mode needs a slot (${SLOTS.join(', ')}).`;
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 'plumbbob: --agent needs a path to an agent directory.';
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, error: `plumbbob: unknown --mode '${flag}' — slots are ${SLOTS.join(', ')}.` };
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
- error: `plumbbob: agent "${manifest.name}" does not declare the '${flag}' slot (it declares ${manifest.slots.join(', ')}).`,
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
- error: `plumbbob: agent "${manifest.name}" declares multiple slots (${manifest.slots.join(', ')}) — pass --mode <slot>.`,
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 `plumbbob: agent "${name}" exited ${result.code} — failed run, stopping. No side effects applied.`;
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 `plumbbob: agent "${name}" is out of contract — ${result.error}`;
332
+ return notice({ fact: `agent "${name}" is out of contract`, detail: [result.error] }).trimEnd();
313
333
  case 'timeout':
314
- return `plumbbob: agent "${name}" timed out after ${result.seconds}s — killed.`;
334
+ return notice({ fact: `agent "${name}" timed out after ${result.seconds}s`, detail: ['killed'] }).trimEnd();
315
335
  case 'interrupted':
316
- return `plumbbob: interrupted — killed agent "${name}".`;
336
+ return notice({ fact: `agent "${name}" killed`, detail: ['interrupted'] }).trimEnd();
317
337
  case 'spawn':
318
- return `plumbbob: could not spawn agent "${name}" — ${result.error}`;
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(`plumbbob: no build-log.md — could not park ${parked.length} line(s) the agent returned.\n`);
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(`plumbbob: no "## Park list" in build-log.md — could not park: ${line}\n`);
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(`plumbbob: parked — ${line}\n`);
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 head = `plumbbob: agent "${name}" (${mode}) — ${envelope.status}: ${envelope.summary}\n`;
360
- const notes = envelope.notes.length > 0 ? ` notes: ${envelope.notes}\n` : '';
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 `${head} blocked — the agent couldn't finish; unblock and re-run.\n${notes}`;
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 `${head} drift — the plan no longer matches reality; /plumbbob:refine before continuing.\n${notes}`;
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 head;
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.
@@ -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('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
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(`plumbbob: build takes one step number; \`${raw}\` step ranges are a \`/plumbbob:build\` feature (auto-approve through the range, then pause). Try \`plumbbob build ${raw.split('-')[0]}\`.\n`);
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('plumbbob: build needs a step number. Try: plumbbob build 2.\n');
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('plumbbob: no undone step to build — every planned step is checkpointed. `/plumbbob:step` to add an increment, or `/plumbbob:finish`.\n');
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(`plumbbob: ${parsed.error} Fix the step's seam in intent.md, then \`build ${step}\` again.\n`);
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
- ? ' (next undone)'
93
+ ? ['next undone']
85
94
  : skipped > 0
86
- ? ` (explicitly requested; skips ${skipped} undone step${skipped === 1 ? '' : 's'})`
87
- : '';
88
- process.stdout.write(`plumbbob: building step ${step}${picked}. Seam (for orientation; not a lock):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
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
  }
@@ -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
- * A broken gate (exit 2) reports distinctly from red: a misconfigured harness
73
- * must never read as broken code, though both block a checkpoint.
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)
@@ -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('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
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('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
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
- ? 'plumbbob: the check gate itself broke — checkpoint refuses until the harness is fixed.\n'
74
- : 'plumbbob: check failed (red) — checkpoint refuses on red. Fix it and re-run.\n');
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
- warnScopeDrift(root, step);
81
- const body = withMarker(`plumbbob step ${step}`, bodyResult.body ?? fallbackBody(root, step));
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
- process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
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
- process.stdout.write(staged
141
- ? `plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`
142
- : `plumbbob: plan committed (record-only — sidecar is gitignored; the plan rides the commit message, the files stay untracked) — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
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
- * Warn (never refuse) when the staged tree reaches beyond the step's seam.
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. The seam
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 warnScopeDrift(root, step) {
224
+ function scopeDriftNotice(root, step) {
189
225
  const seam = seamTokens(root, step);
190
226
  const outside = scopeDrift(stagedPaths(root), seam);
191
- if (outside.length > 0) {
192
- bumpStepStat(root, undefined, step, 'driftWarnings'); // accrues into the build-log's stats receipt
193
- process.stderr.write(`plumbbob: heads-up — staged paths outside step ${step}'s seam: ${outside.join(', ')}. ` +
194
- `The checkpoint captures them; if that's real scope drift, the plan may need a \`/plumbbob:step\` revision.\n`);
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 warns and asks for a hand flip instead.
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
- process.stderr.write(`plumbbob: heads-up — could not flip step ${step} to [x] in intent.md; ` +
233
- `the checkpoint is recorded, but the dashboard will still show step ${step} as next. Flip it by hand.\n`);
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
  */
@@ -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('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
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
- if (existsSync(reportPath(root, slug))) {
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(`plumbbob: finished — ${sha.slice(0, 9)}. ${where} rides your branch into the PR. ` +
79
- 'Run `/plumbbob:plan` (or `plumbbob start "<title>"`) to frame the next goal.\n');
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
  /**