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.
@@ -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)
@@ -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('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
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('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
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
- ? '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');
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
- warnScopeDrift(root, step);
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
- process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
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
- 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`);
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
- * Warn (never refuse) when the staged tree reaches beyond the step's seam.
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. The seam
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 warnScopeDrift(root, step) {
235
+ function scopeDriftNotice(root, step) {
189
236
  const seam = seamTokens(root, step);
190
237
  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
- }
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 warns and asks for a hand flip instead.
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
- 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`);
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
- * Append a dated line to the build-log's `## Log` section.
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 logCheckpoint(root, step, sha) {
323
+ function appendLog(root, line, record) {
245
324
  try {
246
325
  const path = buildLogPath(root);
247
- const date = new Date().toISOString().slice(0, 10);
248
- const line = checkpointLogLine(date, step, sha, titleForStep(root, step), statsSuffix(root, step));
249
- const updated = appendToSection(readFileSync(path, 'utf8'), 'Log', line);
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
  */