plumbbob 0.12.0 → 0.13.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.
Files changed (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -10
  3. package/dist/cli-core.js +25 -3
  4. package/dist/lib/git.js +20 -8
  5. package/dist/lib/notice.js +15 -1
  6. package/dist/lib/orient.js +266 -19
  7. package/dist/lib/sidecar.js +149 -3
  8. package/dist/verbs/abandon.js +19 -14
  9. package/dist/verbs/build.js +45 -22
  10. package/dist/verbs/checkpoint.js +34 -25
  11. package/dist/verbs/doctor.js +23 -4
  12. package/dist/verbs/finish.js +21 -17
  13. package/dist/verbs/handoff.js +235 -65
  14. package/dist/verbs/order.js +110 -0
  15. package/dist/verbs/park.js +6 -2
  16. package/dist/verbs/recover.js +31 -1
  17. package/dist/verbs/relay.js +306 -0
  18. package/dist/verbs/revert.js +17 -11
  19. package/dist/verbs/spike.js +50 -38
  20. package/dist/verbs/start.js +13 -9
  21. package/dist/verbs/status.js +2 -2
  22. package/dist/verbs/turn.js +14 -7
  23. package/dist/verbs/use.js +21 -17
  24. package/hooks/hooks.json +7 -0
  25. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  26. package/node_modules/checkride/CHANGELOG.md +117 -0
  27. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  28. package/node_modules/checkride/dist/adapters.js +14 -10
  29. package/node_modules/checkride/dist/adapters.js.map +1 -1
  30. package/node_modules/checkride/dist/baseline/fallow.d.ts +4 -3
  31. package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -1
  32. package/node_modules/checkride/dist/baseline/fallow.js +15 -8
  33. package/node_modules/checkride/dist/baseline/fallow.js.map +1 -1
  34. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  35. package/node_modules/checkride/dist/gate.js +62 -44
  36. package/node_modules/checkride/dist/gate.js.map +1 -1
  37. package/node_modules/checkride/dist/init.js +3 -3
  38. package/node_modules/checkride/dist/init.js.map +1 -1
  39. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  40. package/node_modules/checkride/dist/orchestrator.js +53 -8
  41. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  42. package/node_modules/checkride/dist/pack.js +3 -3
  43. package/node_modules/checkride/dist/security.d.ts +4 -2
  44. package/node_modules/checkride/dist/security.d.ts.map +1 -1
  45. package/node_modules/checkride/dist/security.js +42 -8
  46. package/node_modules/checkride/dist/security.js.map +1 -1
  47. package/node_modules/checkride/dist/triage/render.js +1 -1
  48. package/node_modules/checkride/dist/triage/render.js.map +1 -1
  49. package/node_modules/checkride/package.json +10 -10
  50. package/node_modules/checkride/skills/check/SKILL.md +4 -3
  51. package/node_modules/checkride/templates/shared/cspell.json +3 -24
  52. package/package.json +3 -3
  53. package/skills/build/SKILL.md +47 -20
  54. package/skills/harvest/SKILL.md +4 -1
  55. package/skills/order/SKILL.md +50 -0
  56. package/skills/park/SKILL.md +7 -0
  57. package/skills/plan/SKILL.md +4 -1
  58. package/skills/refine/SKILL.md +6 -2
  59. package/skills/step/SKILL.md +8 -3
  60. package/skills/verify/SKILL.md +42 -22
  61. package/templates/intent.md +9 -0
@@ -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
  /**
@@ -10,7 +10,7 @@ import { readTemplate, stampTemplate } from "../lib/templates.js";
10
10
  import { findRepoRoot, hasCommit, headSha, isDirty, isIgnored } from "../lib/git.js";
11
11
  import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
12
12
  import { settingsPath } from "../lib/settings.js";
13
- import { advisory, ending, notice, transition } from "../lib/notice.js";
13
+ import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
14
14
  /**
15
15
  * Human-readable echo of the default check gate, stamped into the templates'
16
16
  * {{CHECK}} line.
@@ -177,15 +177,19 @@ export async function start(cwd, args) {
177
177
  // No pointer: the plan skill owns the turn that follows, and there is no step
178
178
  // yet to aim at, so the ending closes on the remedy line that says where to
179
179
  // frame the build.
180
- process.stdout.write(ending({
181
- lead: transition({
182
- label: 'Session',
183
- fact: `started "${title}"`,
184
- detail: [`baseline ${sha.slice(0, 9)}`],
185
- remedy: `frame and decide in ${intentLocation}, then build a step`,
180
+ printEnding(root, {
181
+ tier: 'driver',
182
+ render: 'start',
183
+ text: ending({
184
+ lead: transition({
185
+ label: 'Session',
186
+ fact: `started "${title}"`,
187
+ detail: [`baseline ${sha.slice(0, 9)}`],
188
+ remedy: `frame and decide in ${intentLocation}, then build a step`,
189
+ }),
190
+ advisories,
186
191
  }),
187
- advisories,
188
- }));
192
+ });
189
193
  return 0;
190
194
  }
191
195
  /**
@@ -6,7 +6,7 @@
6
6
  // behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
7
7
  import { readFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
- import { commitsSince, findRepoRoot } from "../lib/git.js";
9
+ import { PLAN_COMMIT_MARKER, commitsSince, findRepoRoot } from "../lib/git.js";
10
10
  import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
11
11
  import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
12
12
  import { formatOrientation, lastLedgerSha, orient, parseRequestedStep } from "../lib/orient.js";
@@ -80,7 +80,7 @@ export function status(cwd, args = []) {
80
80
  inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
81
81
  spiking: inSpike(root, slug),
82
82
  requested: parseRequestedStep(invoked),
83
- outOfBand: anchor === null ? 0 : commitsSince(root, anchor),
83
+ outOfBand: anchor === null ? 0 : commitsSince(root, anchor, PLAN_COMMIT_MARKER),
84
84
  });
85
85
  const lines = [formatOrientation(orientation), ...harnessSection(root, slug)];
86
86
  process.stdout.write(`${lines.join('\n')}\n`);
@@ -10,7 +10,8 @@
10
10
  // could forge would be no grant). When a step is in flight it also emits ONE
11
11
  // `UserPromptSubmit` additionalContext line so a fresh session (post-compaction,
12
12
  // or a scripted `-p` turn where no skill prose is in context) still knows the
13
- // beat: a tangent is parked, not chased. Guidance only: it blocks nothing. It
13
+ // beat: a tangent is parked, not chased, and a message that also asks something
14
+ // is answered before the park's block. Guidance only: it blocks nothing. It
14
15
  // ALWAYS exits 0 and any failure degrades to no output, so neither the tick nor
15
16
  // the emit can ever wedge prompting.
16
17
  import { readFileSync, writeFileSync } from 'node:fs';
@@ -38,10 +39,12 @@ export function turn(cwd, _args) {
38
39
  *
39
40
  * A step is in flight when a STEP marker (the flat control file recording the
40
41
  * step between `build` and `checkpoint`) is present. Guidance only: it reminds a
41
- * fresh session that a tangent is a park (not an edit) and how the step lands,
42
- * since the build prose may not be in context after compaction or on a
43
- * scripted turn. Exported because an in-process test cannot read the hook's
44
- * real stdout.
42
+ * fresh session that a tangent is a park (not an edit), that the park's own
43
+ * block is the whole turn when the message is only a tangent, that a message
44
+ * which also asks something is answered first with the block after the answer,
45
+ * and how the step lands, since neither the build nor the park prose may be in
46
+ * context after compaction or on a scripted turn.
47
+ * Exported because an in-process test cannot read the hook's real stdout.
45
48
  */
46
49
  export function stepInFlightContext(cwd) {
47
50
  const root = findSessionRoot(cwd);
@@ -51,8 +54,12 @@ export function stepInFlightContext(cwd) {
51
54
  if (step === null)
52
55
  return null;
53
56
  const text = `plumbbob: step ${step} is in flight — a new idea or tangent is a park, not an edit: ` +
54
- `capture it with \`plumbbob park "<one line>"\`, then stay on the step. ` +
55
- `It lands when you run \`plumbbob checkpoint ${step}\` after approval.`;
57
+ `capture it with \`plumbbob park "<one line>"\` and relay the block it prints whole, ` +
58
+ `with nothing written around it, since that block is the whole turn. ` +
59
+ `The one exception is a message that also asks something: answer the question first, ` +
60
+ `then relay the park's block after the answer and write nothing after it. ` +
61
+ `The block's pointer back at the step closes the turn either way, so run no \`plumbbob handoff\` after it. ` +
62
+ `The step stays open and lands when you run \`plumbbob checkpoint ${step}\` after approval.`;
56
63
  return `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } })}\n`;
57
64
  }
58
65
  /**
package/dist/verbs/use.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import { existsSync } from 'node:fs';
12
12
  import { findRepoRoot } from "../lib/git.js";
13
13
  import { activeBuild, hasSession, intentPath, listBuilds, setActiveBuild, stepPath } from "../lib/sidecar.js";
14
- import { advisory, ending, notice, transition } from "../lib/notice.js";
14
+ import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
15
15
  import { driverPointer } from "./handoff.js";
16
16
  /**
17
17
  * Switch the active-build cursor to the named build.
@@ -43,22 +43,26 @@ export function use(cwd, args) {
43
43
  // The lead line, then the advisory it qualifies, then the pointer into the
44
44
  // build just switched to: one fixed order for every ending, so the relay never
45
45
  // has to guess which line leads.
46
- process.stdout.write(ending({
47
- lead: transition({
48
- label: 'Active build',
49
- fact: target,
50
- detail: existsSync(stepPath(root, target)) ? ['a step is in flight'] : [],
46
+ printEnding(root, {
47
+ tier: 'driver',
48
+ render: 'use',
49
+ text: ending({
50
+ lead: transition({
51
+ label: 'Active build',
52
+ fact: target,
53
+ detail: existsSync(stepPath(root, target)) ? ['a step is in flight'] : [],
54
+ }),
55
+ advisories: left === null
56
+ ? []
57
+ : [
58
+ advisory({
59
+ fact: `build "${left}" has a step in flight`,
60
+ detail: ['its in-flight state is preserved'],
61
+ remedy: `plumbbob use ${left} to pick it back up`,
62
+ }),
63
+ ],
64
+ pointer: driverPointer(root, target),
51
65
  }),
52
- advisories: left === null
53
- ? []
54
- : [
55
- advisory({
56
- fact: `build "${left}" has a step in flight`,
57
- detail: ['its in-flight state is preserved'],
58
- remedy: `plumbbob use ${left} to pick it back up`,
59
- }),
60
- ],
61
- pointer: driverPointer(root, target),
62
- }));
66
+ });
63
67
  return 0;
64
68
  }
package/hooks/hooks.json CHANGED
@@ -22,6 +22,13 @@
22
22
  { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/plumbbob\" turn" }
23
23
  ]
24
24
  }
25
+ ],
26
+ "Stop": [
27
+ {
28
+ "hooks": [
29
+ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/plumbbob\" relay" }
30
+ ]
31
+ }
25
32
  ]
26
33
  }
27
34
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "checkride",
3
3
  "displayName": "checkride",
4
- "version": "0.12.2",
4
+ "version": "0.12.5",
5
5
  "description": "Bounded, contract-aware readers for what checkride already writes: triage a red gate and read quality signal without loading megabytes of raw artifacts.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://robmclarty.github.io/checkride/",