plumbbob 0.12.1 → 0.13.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.
@@ -14,16 +14,18 @@ import { readFileSync, writeFileSync } from 'node:fs';
14
14
  import { findRepoRoot } from "../lib/git.js";
15
15
  import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
16
16
  import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
17
- import { ending, notice, transition } from "../lib/notice.js";
17
+ import { expandStepList, parseStepList } from "../lib/steplist.js";
18
+ import { ending, notice, printEnding, transition } from "../lib/notice.js";
18
19
  import { driverPointer } from "./handoff.js";
19
20
  /**
20
- * Set the build order to the given step numbers, the rest of the undone steps
21
- * following in document order, or clear it with `--reset`.
21
+ * Set the build order to the given step numbers and spans, the rest of the
22
+ * undone steps following in document order, or clear it with `--reset`.
22
23
  *
23
- * The line written is the whole remaining sequence, so a cold reader of
24
- * intent.md sees every undone step and not only the ones re-sequenced.
25
- * Refuses (exit 1) with no session, no numbers, a token that is not a number,
26
- * a number the plan lacks, a repeated number, or a step already checkpointed.
24
+ * The line written is the whole remaining sequence, each span spelled out, so a
25
+ * cold reader of intent.md sees every undone step and not only the ones
26
+ * re-sequenced. Refuses (exit 1) with no session, no numbers, a token that is
27
+ * neither a number nor a span, a number the plan lacks, a repeated number, or a
28
+ * step already checkpointed.
27
29
  */
28
30
  export function order(cwd, args) {
29
31
  const root = findRepoRoot(cwd);
@@ -35,14 +37,19 @@ export function order(cwd, args) {
35
37
  const intent = readFileSync(path, 'utf8');
36
38
  if (args.includes('--reset')) {
37
39
  writeFileSync(path, clearBuildOrder(intent));
38
- process.stdout.write(ending({
39
- lead: transition({ label: 'Build order', fact: 'document order' }),
40
- pointer: driverPointer(root, activeBuild(root)),
41
- }));
40
+ printEnding(root, {
41
+ tier: 'driver',
42
+ render: 'order',
43
+ text: ending({
44
+ lead: transition({ label: 'Build order', fact: 'document order' }),
45
+ pointer: driverPointer(root, activeBuild(root)),
46
+ }),
47
+ });
42
48
  return 0;
43
49
  }
44
- // Numbers arrive space- or comma-separated: `order 7 8 5` and `order 7,8,5`
45
- // read the same, since the line they write is the comma form.
50
+ // Numbers and spans arrive space- or comma-separated, with or without a space
51
+ // after a comma: `order 7 8 5`, `order 7,8,5`, and `order 1, 6, 2-4, 7, 5`
52
+ // all read, and a span names its steps in place, since the line is a sequence.
46
53
  const tokens = args
47
54
  .filter((a) => !a.startsWith('--'))
48
55
  .flatMap((a) => a.split(','))
@@ -56,14 +63,15 @@ export function order(cwd, args) {
56
63
  }));
57
64
  return 1;
58
65
  }
59
- const stray = tokens.find((t) => !/^\d+$/.test(t));
66
+ const lists = tokens.map(parseStepList);
67
+ const stray = tokens.find((_, i) => lists[i] === null);
60
68
  if (stray !== undefined) {
61
- process.stderr.write(notice({ fact: `\`${stray}\` is not a step number`, remedy: 'plumbbob order 7 8 5' }));
69
+ process.stderr.write(notice({ fact: `\`${stray}\` is not a step number or span`, remedy: 'plumbbob order 1, 6, 2-4, 7, 5' }));
62
70
  return 1;
63
71
  }
64
- const given = tokens.map(Number);
65
72
  const steps = parseSteps(intent);
66
73
  const planned = steps.map((s) => s.n);
74
+ const given = expandStepList(lists.flatMap((list) => list ?? []), (n) => planned.includes(n));
67
75
  const unknown = given.find((n) => !planned.includes(n));
68
76
  if (unknown !== undefined) {
69
77
  process.stderr.write(notice({
@@ -94,9 +102,13 @@ export function order(cwd, args) {
94
102
  // The pointer reads the plan back from disk, so it already follows the
95
103
  // order just written: back at the step in flight, or forward from the
96
104
  // boundary to the first step in the new sequence.
97
- process.stdout.write(ending({
98
- lead: transition({ label: 'Build order', fact: full.join(', ') }),
99
- pointer: driverPointer(root, activeBuild(root)),
100
- }));
105
+ printEnding(root, {
106
+ tier: 'driver',
107
+ render: 'order',
108
+ text: ending({
109
+ lead: transition({ label: 'Build order', fact: full.join(', ') }),
110
+ pointer: driverPointer(root, activeBuild(root)),
111
+ }),
112
+ });
101
113
  return 0;
102
114
  }
@@ -11,7 +11,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
11
11
  import { findRepoRoot } from "../lib/git.js";
12
12
  import { activeBuild, hasSession, buildLogPath } from "../lib/sidecar.js";
13
13
  import { appendToSection } from "../lib/buildlog.js";
14
- import { ending, notice, transition } from "../lib/notice.js";
14
+ import { ending, notice, printEnding, transition } from "../lib/notice.js";
15
15
  import { driverPointer } from "./handoff.js";
16
16
  /**
17
17
  * Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
@@ -43,6 +43,10 @@ export function park(cwd, args) {
43
43
  writeFileSync(path, updated);
44
44
  // A park interrupts a step without ending it, so the ending is the capture
45
45
  // and the pointer back at what was interrupted.
46
- process.stdout.write(ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }));
46
+ printEnding(root, {
47
+ tier: 'driver',
48
+ render: 'park',
49
+ text: ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }),
50
+ });
47
51
  return 0;
48
52
  }
@@ -18,7 +18,7 @@
18
18
  // deleting them is a gamble the human should take by hand.
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import { findRepoRoot } from "../lib/git.js";
21
- import { parseBuildOrder, parseSteps } from "../lib/orient.js";
21
+ import { parseBuildOrder, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding } from "../lib/orient.js";
22
22
  import { activeBuild, checkpointsPath, clearHandoff, clearTick, grantPath, handoffPath, hasSession, intentPath, inSpike, listBuilds, readTurn, setActiveBuild, setGrant, stepPath, tickPath, } from "../lib/sidecar.js";
23
23
  import { spikeBranches, spikeWorktrees } from "./spike.js";
24
24
  /**
@@ -94,6 +94,7 @@ function sessionFindings(root) {
94
94
  const stepInFlight = existsSync(stepPath(root, slug));
95
95
  findings.push(...phaseFindings(root, slug, stepInFlight));
96
96
  findings.push(...orderFindings(root, slug));
97
+ findings.push(...rangeFindings(root, slug));
97
98
  findings.push(...leftoverFindings(root, slug, stepInFlight));
98
99
  return findings;
99
100
  }
@@ -126,6 +127,35 @@ function orderFindings(root, slug) {
126
127
  },
127
128
  ];
128
129
  }
130
+ /**
131
+ * A build range naming a step the plan does not hold.
132
+ *
133
+ * The section under `## Build ranges` is hand-editable, and a refine can drop
134
+ * a step a range names; the card then suggests no command for that range, and
135
+ * the dashboard says why. Reported beside the build order's own finding, never
136
+ * repaired: where the stop belongs now is the human's call. Silent while the
137
+ * plan declares no ranges.
138
+ */
139
+ function rangeFindings(root, slug) {
140
+ const intent = readOr(intentPath(root, slug));
141
+ const plan = parseBuildRanges(intent);
142
+ if (plan.ranges.length === 0) {
143
+ return [];
144
+ }
145
+ const missing = rangeStanding(plan, parseOrderedSteps(intent), null).drift.filter((d) => d.kind === 'missing');
146
+ if (missing.length === 0) {
147
+ const count = `${plan.ranges.length} range${plan.ranges.length === 1 ? '' : 's'}`;
148
+ return [{ name: 'build ranges', ok: true, detail: `every step they name is planned (${count})` }];
149
+ }
150
+ return [
151
+ {
152
+ name: 'build ranges',
153
+ ok: false,
154
+ detail: missing.map((d) => `range ${d.item} names a step the plan lacks: ${d.words}`).join('; '),
155
+ hint: 're-cut the range under ## Build ranges by hand, or with /plumbbob:refine',
156
+ },
157
+ ];
158
+ }
129
159
  /**
130
160
  * The cursor names a build that is not there.
131
161
  *
@@ -0,0 +1,306 @@
1
+ // `plumbbob relay`: the relay check. Wired to the harness's Stop event, it runs
2
+ // as the model ends a turn and asks one question: did the turn carry the ending
3
+ // plumbbob printed in it? Every ending a verb prints is recorded in `RELAY`
4
+ // with its tier and the `TURN` it printed in, and the tier picks the match. A
5
+ // decision ending (a pause card, its reminder, the plan card) has to close the
6
+ // turn, with an answer allowed above it; an orientation ending (a checkpoint's
7
+ // boundary, the plan commit, finish) has to appear somewhere in the turn, since
8
+ // an approving message can carry a question the model answers after it; a
9
+ // driver ending is never checked. A `RELAY` from an earlier turn is not this
10
+ // turn's to carry, so the check runs only while its turn is the current one.
11
+ //
12
+ // It reads a relay by its parts, not line for line: the labeled parts the
13
+ // printed block has, in its order, with no paragraph inside a part that the
14
+ // block does not have there (for a decision ending, after the last part too).
15
+ // Only the measured facts are compared exactly, the Readout
16
+ // table and the Verdict line, because those are what a laundered relay would
17
+ // change; a reworded Summary or Recommendation is the model's own prose, and a
18
+ // reordered Your Call move still offers every move.
19
+ //
20
+ // On a miss it holds the turn open once, through `additionalContext`, with a
21
+ // one-line reason the model acts on and the human reads under `Stop hook
22
+ // feedback`: a decision ending is reprinted by the command `RELAY` recorded,
23
+ // because the stored card goes stale once the model keeps working, and an
24
+ // orientation ending is pasted as it printed, since it reports a commit that
25
+ // has already landed. The once is counted in `RELAY` by the turn it held,
26
+ // never by `stop_hook_active`, which checkride's gate sets on every red Stop
27
+ // and which names no hook. It never sends `continue: false`, which would cancel
28
+ // a sibling's block in the same round.
29
+ //
30
+ // The final message comes from the payload's `last_assistant_message`, never
31
+ // from the transcript, which does not yet hold that message when Stop fires;
32
+ // the transcript supplies only the turn's earlier messages, which an
33
+ // orientation ending can sit in. It ALWAYS exits 0, and anything it cannot read
34
+ // (a payload without the field, a transcript it cannot scope) degrades to no
35
+ // output: a broken check must never wedge a turn's end.
36
+ import { readFileSync } from 'node:fs';
37
+ import { findSessionRoot, holdRelay, readRelay, readTurn } from "../lib/sidecar.js";
38
+ /**
39
+ * The hook entry point: read the Stop payload, and hold the turn open once when
40
+ * it did not carry its ending.
41
+ *
42
+ * Always returns 0: any failure is swallowed and degrades to no output.
43
+ */
44
+ export function relay(cwd, _args) {
45
+ try {
46
+ const output = relayCheck(cwd, readStdin());
47
+ if (output !== null)
48
+ process.stdout.write(output);
49
+ }
50
+ catch {
51
+ // A broken check must never wedge a turn's end: swallow and exit 0.
52
+ }
53
+ return 0;
54
+ }
55
+ /**
56
+ * The hook's stdout for one Stop payload: the `additionalContext` that holds
57
+ * the turn open, or null when the turn carried its ending or there is nothing
58
+ * this check can judge.
59
+ *
60
+ * Exported because an in-process test cannot feed the hook's real stdin.
61
+ */
62
+ export function relayCheck(cwd, raw) {
63
+ const payload = parsePayload(raw);
64
+ // A host too old to send the final message has nothing to check against.
65
+ if (payload === null || typeof payload.last_assistant_message !== 'string')
66
+ return null;
67
+ const root = findSessionRoot(typeof payload.cwd === 'string' ? payload.cwd : cwd);
68
+ if (root === null)
69
+ return null;
70
+ const record = readRelay(root);
71
+ const turn = readTurn(root);
72
+ if (record === null || turn === null || record.turn !== turn || record.held === turn)
73
+ return null;
74
+ const carried = carriedEnding(record, payload.last_assistant_message, payload);
75
+ // Whole, or not this check's to judge: let the turn end.
76
+ if (carried !== false)
77
+ return null;
78
+ // A hold that cannot be counted could repeat, so it is no hold at all.
79
+ if (!holdRelay(root, record))
80
+ return null;
81
+ return `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: reason(record) } })}\n`;
82
+ }
83
+ /**
84
+ * Whether the turn carried the recorded ending: true when it did, false when
85
+ * it did not, and null when the tier is not checked or the turn's earlier text
86
+ * cannot be read to tell.
87
+ */
88
+ function carriedEnding(record, last, payload) {
89
+ const printed = partsOf(record.text);
90
+ // A block with no labeled part is nothing this reader can match against.
91
+ if (printed.length === 0 || record.tier === 'driver')
92
+ return null;
93
+ if (record.tier === 'decision')
94
+ return closesOn(partsOf(last), printed);
95
+ if (appearsIn(partsOf(last), printed))
96
+ return true;
97
+ const earlier = turnText(payload);
98
+ return earlier === null ? null : appearsIn(partsOf(earlier), printed);
99
+ }
100
+ /**
101
+ * Whether a final message closes on the printed block: read from the last
102
+ * place it opens the block's first part, it has the same parts in the same
103
+ * order, the same measured facts, and no paragraph in any part, the last one
104
+ * included, that the block does not have there.
105
+ */
106
+ function closesOn(relayed, printed) {
107
+ const first = printed[0]?.label;
108
+ const start = relayed.findLastIndex((p) => p.label === first);
109
+ return start !== -1 && sameParts(relayed.slice(start), printed, printed.length);
110
+ }
111
+ /**
112
+ * Whether the printed block appears anywhere in a stretch of text: some place
113
+ * that opens its first part runs on through the same parts in the same order,
114
+ * with the same measured facts and nothing written inside them. Text after the
115
+ * last part is the turn's to say, so that part's paragraphs are not counted.
116
+ */
117
+ function appearsIn(relayed, printed) {
118
+ return relayed.some((p, i) => p.label === printed[0]?.label && sameParts(relayed.slice(i, i + printed.length), printed, printed.length - 1));
119
+ }
120
+ /**
121
+ * Whether two runs of parts carry the same labels in order and the same
122
+ * measured facts, with no more paragraphs than the printed block has in each
123
+ * of the first `counted` parts.
124
+ */
125
+ function sameParts(relayed, printed, counted) {
126
+ if (relayed.length !== printed.length || relayed.some((p, i) => p.label !== printed[i]?.label))
127
+ return false;
128
+ const crowded = relayed.slice(0, counted).some((p, i) => blockCount(p) > blockCount(printed[i]));
129
+ return !crowded && factsOf(relayed) === factsOf(printed);
130
+ }
131
+ /**
132
+ * The facts the CLI measured, as one comparable string: the Readout table,
133
+ * whitespace aside, and the Verdict line. Every other line of a block may be
134
+ * reworded or reordered without the relay counting as missed.
135
+ */
136
+ function factsOf(parts) {
137
+ const readout = parts.find((p) => p.label === 'Readout');
138
+ const verdict = parts.find((p) => p.label === 'Verdict');
139
+ return `${flat(readout === undefined ? '' : tableOf(readout))}\n${flat(verdict?.lines[0] ?? '')}`;
140
+ }
141
+ /** The `text` fence inside the Readout part, rails included, or '' when it has none. */
142
+ function tableOf(part) {
143
+ const open = part.lines.findIndex((l) => /^`{3,}text\s*$/.test(l));
144
+ if (open === -1)
145
+ return '';
146
+ const rail = /^`+/.exec(part.lines[open] ?? '')?.[0] ?? '```';
147
+ const close = part.lines.findIndex((l, i) => i > open && l.trim() === rail);
148
+ return part.lines.slice(open, close === -1 ? undefined : close + 1).join('\n');
149
+ }
150
+ /**
151
+ * How many blocks a part holds: runs of lines between blank lines, a fence
152
+ * counted whole, and the items of one list counted once even when a relay
153
+ * spaced them apart. A paragraph after a part's last block is the model
154
+ * talking past the ending.
155
+ */
156
+ function blockCount(part) {
157
+ let count = 0;
158
+ let open = false;
159
+ let listing = false;
160
+ let inFence = false;
161
+ for (const line of part.lines) {
162
+ if (/^\s*`{3,}/.test(line))
163
+ inFence = !inFence;
164
+ if (line.trim().length === 0 && !inFence) {
165
+ open = false;
166
+ continue;
167
+ }
168
+ const item = /^\s*(?:[-*]|\d+\.)\s/.test(line);
169
+ if (!open && !(item && listing))
170
+ count += 1;
171
+ open = true;
172
+ listing = item;
173
+ }
174
+ return count;
175
+ }
176
+ /**
177
+ * An ending's labeled parts, in order: each opens on a line that starts with a
178
+ * bold label and a colon, outside a fence, and runs to the next. Text before
179
+ * the first label is not part of any.
180
+ */
181
+ function partsOf(text) {
182
+ const parts = [];
183
+ let rail = null;
184
+ for (const line of text.split('\n')) {
185
+ const fence = /^\s*(`{3,})/.exec(line)?.[1];
186
+ if (rail === null && fence !== undefined) {
187
+ rail = fence;
188
+ }
189
+ else if (rail !== null && fence !== undefined && fence.length >= rail.length && line.trim() === fence) {
190
+ rail = null;
191
+ parts.at(-1)?.lines.push(line);
192
+ continue;
193
+ }
194
+ const label = rail === null ? /^\*\*([^*\n]+)\*\*:(?:\s|$)/.exec(line)?.[1] : undefined;
195
+ if (label !== undefined) {
196
+ parts.push({ label, lines: [line] });
197
+ }
198
+ else {
199
+ parts.at(-1)?.lines.push(line);
200
+ }
201
+ }
202
+ return parts;
203
+ }
204
+ /**
205
+ * The one-line reason the model acts on and the human reads in full: what the
206
+ * turn missed, and the command or block that puts it back.
207
+ */
208
+ function reason(record) {
209
+ if (record.tier === 'decision') {
210
+ const command = record.command ?? 'plumbbob handoff --full';
211
+ return (`plumbbob: the turn did not close on the ${DECISION_NAMES[record.render] ?? 'pause'} it printed. ` +
212
+ `Run \`${command}\` and end the turn on its output, whole, with nothing after it.`);
213
+ }
214
+ const verb = ORIENTATION_VERBS[record.render] ?? 'plumbbob handoff';
215
+ return (`plumbbob: the turn did not relay the block \`${verb}\` printed. ` +
216
+ 'Paste that block whole, exactly as it printed (it is stored below the header in `.plumbbob/RELAY`).');
217
+ }
218
+ // What a decision ending is called in the reason, by its render.
219
+ const DECISION_NAMES = {
220
+ card: 'pause card',
221
+ reminder: 'pause reminder',
222
+ plan: 'plan card',
223
+ };
224
+ // The command that printed an orientation ending, by its render, so the reason
225
+ // names the block the model already has in front of it.
226
+ const ORIENTATION_VERBS = {
227
+ checkpoint: 'plumbbob checkpoint',
228
+ 'plan-commit': 'plumbbob checkpoint --plan',
229
+ finish: 'plumbbob finish',
230
+ };
231
+ /**
232
+ * The text of the turn's messages already on disk: every assistant text part
233
+ * after the human prompt the payload's `prompt_id` names.
234
+ *
235
+ * Null when the transcript cannot be read or that prompt is not in it: a turn
236
+ * the check cannot scope is a turn it does not judge. A line that does not
237
+ * parse is skipped, since the one being written as Stop fires is the final
238
+ * message, which the payload carries already.
239
+ */
240
+ function turnText(payload) {
241
+ if (typeof payload.transcript_path !== 'string' || typeof payload.prompt_id !== 'string')
242
+ return null;
243
+ let lines;
244
+ try {
245
+ lines = readFileSync(payload.transcript_path, 'utf8').split('\n');
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ const entries = lines.map(parseEntry).filter((e) => e !== null);
251
+ const start = entries.findIndex((e) => e.type === 'user' && e.promptId === payload.prompt_id && e.isMeta !== true);
252
+ if (start === -1)
253
+ return null;
254
+ return entries
255
+ .slice(start + 1)
256
+ .filter((e) => e.type === 'assistant')
257
+ .flatMap((e) => {
258
+ const content = e.message?.content;
259
+ return Array.isArray(content) ? content : [];
260
+ })
261
+ .flatMap((part) => (part?.type === 'text' && typeof part.text === 'string' ? [part.text] : []))
262
+ .join('\n');
263
+ }
264
+ /** One transcript line as an entry, or null when it is blank or does not parse. */
265
+ function parseEntry(line) {
266
+ if (line.trim().length === 0)
267
+ return null;
268
+ try {
269
+ const entry = JSON.parse(line);
270
+ return typeof entry === 'object' && entry !== null ? entry : null;
271
+ }
272
+ catch {
273
+ return null;
274
+ }
275
+ }
276
+ /** The Stop payload, or null when stdin is not a JSON object. */
277
+ function parsePayload(raw) {
278
+ try {
279
+ const parsed = JSON.parse(raw);
280
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null;
281
+ }
282
+ catch {
283
+ return null;
284
+ }
285
+ }
286
+ /**
287
+ * Text with every run of whitespace collapsed to one space and the ends
288
+ * trimmed, so a measured fact still matches when a relay rewrapped it.
289
+ */
290
+ function flat(text) {
291
+ return text.replace(/\s+/g, ' ').trim();
292
+ }
293
+ /**
294
+ * The hook payload from stdin, or '' on a TTY or any read failure: a check
295
+ * that cannot read its payload judges nothing.
296
+ */
297
+ function readStdin() {
298
+ if (process.stdin.isTTY === true)
299
+ return '';
300
+ try {
301
+ return readFileSync(0, 'utf8');
302
+ }
303
+ catch {
304
+ return '';
305
+ }
306
+ }
@@ -20,10 +20,10 @@ import { tmpdir } from 'node:os';
20
20
  import { join } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
23
- import { bumpStepStat, checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
23
+ import { bumpStepStat, checkpointsPath, clearPause, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
24
24
  import { isArtifactPath, matchesSeam } from "../lib/intent.js";
25
25
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
26
- import { ending, notice, transition } from "../lib/notice.js";
26
+ import { ending, notice, printEnding, transition } from "../lib/notice.js";
27
27
  import { driverPointer } from "./handoff.js";
28
28
  /**
29
29
  * Rewind to a recorded checkpoint and return the build to the boundary.
@@ -31,8 +31,9 @@ import { driverPointer } from "./handoff.js";
31
31
  * Requires an active session (the `.plumbbob/STATE` sentinel). Resolves the
32
32
  * target SHA from the checkpoints ledger (latest step, `--to n`, or the
33
33
  * baseline), resets hard while preserving plumbbob-owned paths, removes
34
- * untracked files inside the seam, clears the in-flight STEP/SEAM markers, and
35
- * re-renders the build-log. Exit 1 with a hint on any refusal.
34
+ * untracked files inside the seam, clears the in-flight STEP/SEAM markers and
35
+ * the PAUSE stamp, and re-renders the build-log. Exit 1 with a hint on any
36
+ * refusal.
36
37
  */
37
38
  export function revert(cwd, args) {
38
39
  const root = findRepoRoot(cwd);
@@ -77,6 +78,7 @@ export function revert(cwd, args) {
77
78
  }
78
79
  rmSync(seamPath(root, slug), { force: true });
79
80
  rmSync(stepPath(root, slug), { force: true });
81
+ clearPause(root, slug); // the pause it stamped is gone with the work
80
82
  if (inFlight !== null) {
81
83
  bumpStepStat(root, slug, inFlight, 'reverts');
82
84
  }
@@ -87,14 +89,18 @@ export function revert(cwd, args) {
87
89
  syncBuildLogState(root, slug, AT_BOUNDARY);
88
90
  // A step exit: nothing landed, so no Verdict rides above the pointer, and with
89
91
  // the markers cleared it aims forward from the boundary.
90
- process.stdout.write(ending({
91
- lead: transition({
92
- label: 'Reverted',
93
- fact: `to ${sha.slice(0, 9)}`,
94
- detail: ['park lines and intent edits preserved'],
92
+ printEnding(root, {
93
+ tier: 'driver',
94
+ render: 'revert',
95
+ text: ending({
96
+ lead: transition({
97
+ label: 'Reverted',
98
+ fact: `to ${sha.slice(0, 9)}`,
99
+ detail: ['park lines and intent edits preserved'],
100
+ }),
101
+ pointer: driverPointer(root, slug),
95
102
  }),
96
- pointer: driverPointer(root, slug),
97
- }));
103
+ });
98
104
  return 0;
99
105
  }
100
106
  /**
@@ -21,7 +21,7 @@ import { basename, dirname, join, relative } from 'node:path';
21
21
  import { findRepoRoot } from "../lib/git.js";
22
22
  import { buildFolder, clearSpike, hasSession, inSpike, listSpikeReports, markSpike, nextSpikeReportPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
23
23
  import { readTemplate, stampTemplate } from "../lib/templates.js";
24
- import { advisory, blocks, ending, notice, transition } from "../lib/notice.js";
24
+ import { advisory, blocks, ending, notice, printEnding, transition } from "../lib/notice.js";
25
25
  import { driverPointer } from "./handoff.js";
26
26
  // Worktree/branch names when the caller lists none: a fork defaults to two arms.
27
27
  const DEFAULT_OPTIONS = ['a', 'b'];
@@ -69,15 +69,19 @@ function spikeReport(root, buildSlug, positionals) {
69
69
  const inFlight = readInFlightStep(root, buildSlug);
70
70
  const via = inFlight !== null ? `step ${inFlight}` : '/plumbbob:spike';
71
71
  const path = scaffoldSpikeReport(root, buildSlug, slug, via);
72
- process.stdout.write(ending({
73
- lead: transition({
74
- label: 'Spike report',
75
- fact: 'scaffolded',
76
- detail: [relative(root, path)],
77
- remedy: 'record Findings and the Verdict there, which is what closes a spike step',
72
+ printEnding(root, {
73
+ tier: 'driver',
74
+ render: 'spike-report',
75
+ text: ending({
76
+ lead: transition({
77
+ label: 'Spike report',
78
+ fact: 'scaffolded',
79
+ detail: [relative(root, path)],
80
+ remedy: 'record Findings and the Verdict there, which is what closes a spike step',
81
+ }),
82
+ pointer: driverPointer(root, buildSlug),
78
83
  }),
79
- pointer: driverPointer(root, buildSlug),
80
- }));
84
+ });
81
85
  return 0;
82
86
  }
83
87
  /**
@@ -158,22 +162,26 @@ function spikeStart(root, buildSlug, positionals) {
158
162
  // The lead states the fact; the throwaway worktrees are a list, so they ride
159
163
  // as a readout beneath it rather than crowding the line, and the report the
160
164
  // spike scaffolded is a second landing under the same lead.
161
- process.stdout.write(ending({
162
- lead: blocks([
163
- transition({
164
- label: 'Spike',
165
- fact: 'opened',
166
- detail: ['the main tree stays put', `${created.length} throwaway worktree${created.length === 1 ? '' : 's'}`],
167
- }) + created.map((path) => ` ${path}`).join('\n'),
168
- transition({
169
- label: 'Spike report',
170
- fact: 'scaffolded',
171
- detail: [relative(root, report)],
172
- remedy: 'record findings and the Verdict there, then run plumbbob spike done',
173
- }),
174
- ]),
175
- pointer: driverPointer(root, buildSlug),
176
- }));
165
+ printEnding(root, {
166
+ tier: 'driver',
167
+ render: 'spike-open',
168
+ text: ending({
169
+ lead: blocks([
170
+ transition({
171
+ label: 'Spike',
172
+ fact: 'opened',
173
+ detail: ['the main tree stays put', `${created.length} throwaway worktree${created.length === 1 ? '' : 's'}`],
174
+ }) + created.map((path) => ` ${path}`).join('\n'),
175
+ transition({
176
+ label: 'Spike report',
177
+ fact: 'scaffolded',
178
+ detail: [relative(root, report)],
179
+ remedy: 'record findings and the Verdict there, then run plumbbob spike done',
180
+ }),
181
+ ]),
182
+ pointer: driverPointer(root, buildSlug),
183
+ }),
184
+ });
177
185
  return 0;
178
186
  }
179
187
  /**
@@ -203,19 +211,23 @@ function spikeDone(root, buildSlug) {
203
211
  // closes. It follows the line it qualifies, and names the reports so the
204
212
  // human knows where to write the call. The pointer, read once the marker is
205
213
  // cleared, aims back at the step the spike interrupted, or forward.
206
- process.stdout.write(ending({
207
- lead: transition({ label: 'Spike', fact: 'closed', detail: ['worktrees and branches removed'] }),
208
- advisories: unfilled.length === 0
209
- ? []
210
- : [
211
- advisory({
212
- fact: 'no verdict recorded',
213
- detail: unfilled,
214
- remedy: 'record which option won, and why, now the worktrees are gone',
215
- }),
216
- ],
217
- pointer: driverPointer(root, buildSlug),
218
- }));
214
+ printEnding(root, {
215
+ tier: 'driver',
216
+ render: 'spike-done',
217
+ text: ending({
218
+ lead: transition({ label: 'Spike', fact: 'closed', detail: ['worktrees and branches removed'] }),
219
+ advisories: unfilled.length === 0
220
+ ? []
221
+ : [
222
+ advisory({
223
+ fact: 'no verdict recorded',
224
+ detail: unfilled,
225
+ remedy: 'record which option won, and why, now the worktrees are gone',
226
+ }),
227
+ ],
228
+ pointer: driverPointer(root, buildSlug),
229
+ }),
230
+ });
219
231
  return 0;
220
232
  }
221
233
  /**