plumbbob 0.13.3 → 0.13.5

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.
@@ -10,8 +10,10 @@
10
10
  // A stop's record is latched (D103 (stop-latched)). It refuses in the turn
11
11
  // the stop card printed, since a run's long turn ends on proposals and that is
12
12
  // where a model would carry straight on into recording its own; it refuses a
13
- // range's detail no stop card showed, read off the digest the card's STOP
14
- // stamp keeps; and it refuses a range that no longer reads as landed, telling
13
+ // range no stop card showed, read off the range the card's STOP stamp names,
14
+ // though not a detail file rewritten since, which is a lean the human changed
15
+ // in a reply (D110 (reply-after-the-card)); and it refuses a range that no
16
+ // longer reads as landed, telling
15
17
  // a step landed and reopened from one that joined the range after it landed,
16
18
  // which moves to a range still to come. A harvest outside a stop (`# Detail · Harvest`) needs no stamp. A proposal
17
19
  // naming a handle the list lacks (`P9` with eight parked), or a class outside
@@ -24,9 +26,9 @@ import { ending, notice, printEnding, transition } from "../lib/notice.js";
24
26
  import { appendHarvest, appendToSection, flipParks, harvestLines, logEntry, stopLogLine } from "../lib/buildlog.js";
25
27
  import { checkStopLatch } from "../lib/latch.js";
26
28
  import { parseBuildRanges, parseDetailStep, parseLastCheckpoint, parseOrderedSteps, stepShas } from "../lib/orient.js";
27
- import { activeBuild, buildLogPath, checkpointsPath, clearDetail, clearStop, detailDigest, detailPath, hasSession, intentPath, readStop, } from "../lib/sidecar.js";
29
+ import { activeBuild, buildLogPath, checkpointsPath, clearDetail, clearStop, detailPath, hasSession, intentPath, readStop, } from "../lib/sidecar.js";
28
30
  import { hasStep } from "../lib/steplist.js";
29
- import { boundaryEnding, harvestProposals, parkList, parseDetailRange, stopRecord } from "./handoff.js";
31
+ import { boundaryEnding, harvestProposals, isHarvestDetail, parkList, parseDetailRange, stopRecord, } from "./handoff.js";
30
32
  // The three classes a park settles as, in the order the ending counts them.
31
33
  const CLASSES = ['blocker', 'tangent', 'pivot'];
32
34
  // Where a refusal about the proposals sends the model: the file it wrote them in.
@@ -57,7 +59,7 @@ export function harvest(cwd, _args) {
57
59
  const parks = parkList(buildLog);
58
60
  let stop = null;
59
61
  if (item !== null) {
60
- const found = stopRange(root, slug, intent, detail, item);
62
+ const found = stopRange(root, slug, intent, item);
61
63
  if (typeof found === 'string') {
62
64
  process.stderr.write(found);
63
65
  return 1;
@@ -101,26 +103,31 @@ export function harvest(cwd, _args) {
101
103
  printEnding(root, {
102
104
  tier: 'orientation',
103
105
  render: 'harvest',
104
- text: ending({ lead, pointer: boundaryEnding(root, slug, null).pointer }),
106
+ text: ending({ lead, state: boundaryEnding(root, slug, null).state }),
105
107
  });
106
108
  return 0;
107
109
  }
108
110
  /**
109
111
  * The range a range's detail reviews, once the record may land: the plan
110
- * holds the range, a stop card showed this detail, a human turn has read it,
112
+ * holds the range, a stop card for it has shown, a human turn has read it,
111
113
  * and every step of the range has landed. Otherwise the refusal, with the move
112
114
  * that unblocks it.
113
115
  *
116
+ * The card need not have shown this detail file byte for byte: a reply that
117
+ * changed a lean rewrote the file without printing the card again, and the
118
+ * change is the human's own (D110 (reply-after-the-card)).
119
+ *
114
120
  * An undone step in the range reads two ways, told apart by the ledger. One
115
121
  * with a `step N <sha>` line landed and was reopened, so it lands again and
116
- * the card re-renders. One with none joined the range after the card, a
122
+ * the card prints again on `--stop --full`, since the range it reviews has
123
+ * changed under it. One with none joined the range after the card, a
117
124
  * blocker folded into the range that just landed, and `handoff --stop` would
118
125
  * refuse too, since the last landing no longer completes the range; so the
119
126
  * remedy moves the step to a range still to come and runs the record again,
120
127
  * which holds in the same reply while the detail file stays as the card
121
128
  * showed it.
122
129
  */
123
- function stopRange(root, slug, intent, detail, item) {
130
+ function stopRange(root, slug, intent, item) {
124
131
  const plan = parseBuildRanges(intent);
125
132
  const count = plan.ranges.length + plan.malformed.length;
126
133
  const range = plan.ranges.find((r) => r.item === item);
@@ -129,22 +136,22 @@ function stopRange(root, slug, intent, detail, item) {
129
136
  return refuse(`the plan has no range ${item}`, `write the range's number in the header of .plumbbob/detail.md`);
130
137
  }
131
138
  const stamp = readStop(root, slug);
132
- if (stamp === null || stamp.range !== item || stamp.detail !== detailDigest(detail)) {
133
- return refuse('no stop card showed its detail', 'plumbbob handoff --stop, then end the turn on the card');
139
+ if (stamp === null || stamp.range !== item) {
140
+ return refuse('no stop card showed it', 'plumbbob handoff --stop, then end the turn on the card');
134
141
  }
135
142
  if (!checkStopLatch(root, stamp.turn)) {
136
143
  return refuse("its card printed this turn, and no human turn has read it", "end the turn on the card; the human's reply is what records it");
137
144
  }
138
145
  const held = parseOrderedSteps(intent).filter((s) => hasStep(range.steps, s.n));
139
146
  if (held.length === 0) {
140
- return refuse('the plan holds none of its steps', 'land it, then plumbbob handoff --stop');
147
+ return refuse('the plan holds none of its steps', 'land it, then plumbbob handoff --stop --full');
141
148
  }
142
149
  const open = held.find((s) => !s.done);
143
150
  if (open !== undefined) {
144
151
  // A step the ledger landed was reopened since, and lands again; one it never
145
152
  // landed was folded into the range after the card, and moves out instead.
146
153
  return stepShas(readOr(checkpointsPath(root, slug))).has(open.n)
147
- ? refuse(`step ${open.n} is open again`, 'land it, then plumbbob handoff --stop')
154
+ ? refuse(`step ${open.n} is open again`, 'land it, then plumbbob handoff --stop --full')
148
155
  : refuse(`step ${open.n} joined it after it landed`, `move step ${open.n} to a range still to come, then plumbbob harvest`);
149
156
  }
150
157
  return { item, count, stop: range.stop };
@@ -183,14 +190,6 @@ function boundaryWords(root, slug) {
183
190
  const last = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
184
191
  return last === null ? 'before the first step landed' : `at the step ${last.n} boundary`;
185
192
  }
186
- /**
187
- * Whether a detail file is a boundary harvest's wire, read off its
188
- * `# Detail · Harvest` header.
189
- */
190
- export function isHarvestDetail(detail) {
191
- const first = detail.trimStart().split('\n', 1)[0] ?? '';
192
- return /^#\s+Detail\s*·\s*Harvest\s*$/.test(first);
193
- }
194
193
  /**
195
194
  * The refusal for a detail file that is no range's and no harvest's: a step's
196
195
  * is checkpoint's to record, and an empty one, or one with no header, records nothing.
@@ -7,15 +7,18 @@
7
7
  // `checkpoint`, `handoff`), so the dashboard's `← next`, a bare
8
8
  // `plumbbob build`, and the card's Next Up agree. The verb validates what a
9
9
  // hand edit cannot: every number names an undone step, none twice. `--reset`
10
- // drops the line, and the plan reads in document order again. It runs in any
11
- // state, since re-sequencing what comes after the step in flight is the
12
- // mid-step use, and the sidecar sits outside every seam.
10
+ // drops the line, and the plan reads in document order again. Either way the
11
+ // build ranges are re-cut to follow the order written, wherever the order
12
+ // settles which range a step is in, so a step placed next runs in the range
13
+ // it now builds with. It runs in any state, since re-sequencing what comes
14
+ // after the step in flight is the mid-step use, and the sidecar sits outside
15
+ // every seam.
13
16
  import { readFileSync, writeFileSync } from 'node:fs';
14
17
  import { findRepoRoot } from "../lib/git.js";
15
18
  import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
16
- import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
17
- import { expandStepList, parseStepList } from "../lib/steplist.js";
18
- import { ending, notice, printEnding, transition } from "../lib/notice.js";
19
+ import { clearBuildOrder, orderSteps, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding, recutPlan, setBuildOrder, } from "../lib/orient.js";
20
+ import { expandStepList, formatStepList, parseStepList } from "../lib/steplist.js";
21
+ import { advisory, blocks, ending, notice, printEnding, transition } from "../lib/notice.js";
19
22
  import { driverPointer } from "./handoff.js";
20
23
  /**
21
24
  * Set the build order to the given step numbers and spans, the rest of the
@@ -36,15 +39,7 @@ export function order(cwd, args) {
36
39
  const path = intentPath(root);
37
40
  const intent = readFileSync(path, 'utf8');
38
41
  if (args.includes('--reset')) {
39
- writeFileSync(path, clearBuildOrder(intent));
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
+ writeOrder(root, path, clearBuildOrder(intent), 'document order');
48
43
  return 0;
49
44
  }
50
45
  // Numbers and spans arrive space- or comma-separated, with or without a space
@@ -98,17 +93,66 @@ export function order(cwd, args) {
98
93
  const full = orderSteps(steps, given)
99
94
  .filter((s) => !s.done)
100
95
  .map((s) => s.n);
101
- writeFileSync(path, setBuildOrder(intent, full));
102
- // The pointer reads the plan back from disk, so it already follows the
103
- // order just written: back at the step in flight, or forward from the
104
- // boundary to the first step in the new sequence.
96
+ writeOrder(root, path, setBuildOrder(intent, full), full.join(', '));
97
+ return 0;
98
+ }
99
+ /**
100
+ * Write the plan under its new build order, its ranges re-cut to follow it,
101
+ * and print the ending: the order, the ranges the re-cut changed, any range it
102
+ * left for the human to re-cut, and the pointer.
103
+ *
104
+ * The pointer reads the plan back from disk, so it already follows the order
105
+ * just written: back at the step in flight, or forward from the boundary to
106
+ * the first step in the new sequence.
107
+ */
108
+ function writeOrder(root, path, ordered, sequence) {
109
+ const { intent, recut } = recutPlan(ordered);
110
+ writeFileSync(path, intent);
111
+ const standing = rangeStanding(parseBuildRanges(intent), parseOrderedSteps(intent), null);
105
112
  printEnding(root, {
106
113
  tier: 'driver',
107
114
  render: 'order',
108
115
  text: ending({
109
- lead: transition({ label: 'Build order', fact: full.join(', ') }),
116
+ lead: blocks([transition({ label: 'Build order', fact: sequence }), recutLead(recut, standing.count)]),
117
+ advisories: rangeAdvisories(standing),
110
118
  pointer: driverPointer(root, activeBuild(root)),
111
119
  }),
112
120
  });
113
- return 0;
121
+ }
122
+ /**
123
+ * The lead naming each range the re-cut changed and the steps it names now,
124
+ * `range 2 of 3 re-cut (steps 10,4-6)`; null when it changed none.
125
+ */
126
+ function recutLead(recut, count) {
127
+ const [only] = recut;
128
+ if (only === undefined) {
129
+ return null;
130
+ }
131
+ const steps = (cut) => `${cut.steps.length === 1 ? 'step' : 'steps'} ${formatStepList(cut.steps)}`;
132
+ const items = recut.map((cut) => cut.item).join(', ');
133
+ return transition({
134
+ label: 'Build ranges',
135
+ fact: `${recut.length === 1 ? 'range' : 'ranges'} ${items} of ${count} re-cut`,
136
+ detail: recut.length === 1 ? [steps(only)] : recut.map((cut) => `range ${cut.item}: ${steps(cut)}`),
137
+ });
138
+ }
139
+ /**
140
+ * Every range still flagged once the re-cut is written, in the words the
141
+ * dashboard flags it with, and beneath the last the move that re-cuts it by
142
+ * hand: the ties the order did not settle, a step no range can take, and the
143
+ * plan's other drift. Empty when every undone step sits in one stretch of one
144
+ * range.
145
+ */
146
+ function rangeAdvisories(standing) {
147
+ const { count, drift } = standing;
148
+ const own = drift.filter((d) => d.item !== null && d.kind !== 'malformed');
149
+ const flagged = [...new Set(own.map((d) => d.item))].map((item) => ({
150
+ fact: `range ${item} of ${count} needs a re-cut`,
151
+ detail: own.filter((d) => d.item === item).map((d) => d.words),
152
+ }));
153
+ const lines = [...flagged, ...drift.filter((d) => d.item === null || d.kind === 'malformed').map((d) => ({ fact: d.words }))];
154
+ if (lines.length === 0) {
155
+ return [];
156
+ }
157
+ return [lines.map((l, i) => advisory(i === lines.length - 1 ? { ...l, remedy: '/plumbbob:refine ranges' } : l)).join('')];
114
158
  }
@@ -66,7 +66,9 @@ export function relayCheck(cwd, raw) {
66
66
  // A host too old to send the final message has nothing to check against.
67
67
  if (payload === null || typeof payload.last_assistant_message !== 'string')
68
68
  return null;
69
- const root = findSessionRoot(typeof payload.cwd === 'string' ? payload.cwd : cwd);
69
+ // The session the payload names, else the one the process stands in: the
70
+ // rule the turn hook ticks by, so the two always read the same session.
71
+ const root = (typeof payload.cwd === 'string' ? findSessionRoot(payload.cwd) : null) ?? findSessionRoot(cwd);
70
72
  if (root === null)
71
73
  return null;
72
74
  const record = readRelay(root);
@@ -135,13 +137,24 @@ function sameParts(relayed, printed, counted) {
135
137
  }
136
138
  /**
137
139
  * The facts the CLI measured, as one comparable string: the Readout table,
138
- * whitespace aside, and the Verdict line. Every other line of a block may be
140
+ * whitespace aside, and the Verdict's fact. Every other line of a block may be
139
141
  * reworded or reordered without the relay counting as missed.
140
142
  */
141
143
  function factsOf(parts) {
142
144
  const readout = parts.find((p) => p.label === 'Readout');
143
145
  const verdict = parts.find((p) => p.label === 'Verdict');
144
- return `${flat(readout === undefined ? '' : tableOf(readout))}\n${flat(verdict?.lines[0] ?? '')}`;
146
+ return `${flat(readout === undefined ? '' : tableOf(readout))}\n${flat(verdict === undefined ? '' : verdictOf(verdict))}`;
147
+ }
148
+ /**
149
+ * The Verdict's fact: the second cell of the state table's header row, where
150
+ * a card carries it (D111 (state-table)), or the text after the label on the
151
+ * line an older ending printed. Read as the fact, not the line, so a relay
152
+ * that changed the spacing around the table's pipes still carries the Verdict
153
+ * it printed.
154
+ */
155
+ function verdictOf(part) {
156
+ const first = part.lines[0] ?? '';
157
+ return stateRowOf(first)?.value ?? first.replace(/^\*\*Verdict\*\*:\s*/, '');
145
158
  }
146
159
  /** The `text` fence inside the Readout part, rails included, or '' when it has none. */
147
160
  function tableOf(part) {
@@ -180,8 +193,8 @@ function blockCount(part) {
180
193
  }
181
194
  /**
182
195
  * An ending's labeled parts, in order: each opens on a line that starts with a
183
- * bold label and a colon, outside a fence, and runs to the next. Text before
184
- * the first label is not part of any.
196
+ * bold label and a colon, or on a row of the state table, outside a fence, and
197
+ * runs to the next. Text before the first label is not part of any.
185
198
  */
186
199
  function partsOf(text) {
187
200
  const parts = [];
@@ -196,7 +209,7 @@ function partsOf(text) {
196
209
  parts.at(-1)?.lines.push(line);
197
210
  continue;
198
211
  }
199
- const label = rail === null ? /^\*\*([^*\n]+)\*\*:(?:\s|$)/.exec(line)?.[1] : undefined;
212
+ const label = rail === null ? labelOf(line) : undefined;
200
213
  if (label !== undefined) {
201
214
  parts.push({ label, lines: [line] });
202
215
  }
@@ -206,6 +219,35 @@ function partsOf(text) {
206
219
  }
207
220
  return parts;
208
221
  }
222
+ /**
223
+ * The label a line opens a part on, or undefined when it opens none: a bold
224
+ * label and a colon at the line's start, or a state-table row, whose first
225
+ * cell is its label. Each row of the table is a part of its own, so the
226
+ * table's rows are read in order as the labeled lines were.
227
+ */
228
+ function labelOf(line) {
229
+ return /^\*\*([^*\n]+)\*\*:(?:\s|$)/.exec(line)?.[1] ?? stateRowOf(line)?.label;
230
+ }
231
+ /**
232
+ * A state-table row's label and fact, or null for any other line: a table row
233
+ * of two cells, its label with the bold stripped. The delimiter row and the
234
+ * lean table's four-cell rows are no state row, so they stay inside the part
235
+ * above them.
236
+ */
237
+ function stateRowOf(line) {
238
+ const cells = tableCells(line);
239
+ const [label, value] = cells ?? [];
240
+ if (cells === null || cells.length !== 2 || label === undefined || value === undefined || /^:?-{3,}:?$/.test(label))
241
+ return null;
242
+ return { label: label.replace(/^\*\*(.*)\*\*$/, '$1').trim(), value };
243
+ }
244
+ /** The cells of a markdown table row, split on unescaped pipes, or null when the line is not one. */
245
+ function tableCells(line) {
246
+ const text = line.trim();
247
+ if (text.length < 2 || !text.startsWith('|') || !text.endsWith('|'))
248
+ return null;
249
+ return text.slice(1, -1).split(/(?<!\\)\|/).map((c) => c.trim());
250
+ }
209
251
  /**
210
252
  * The one-line reason the model acts on and the human reads in full: what the
211
253
  * turn missed, and the command or block that puts it back.
@@ -13,28 +13,37 @@
13
13
  // range the run is landing. The harness also sends a background job's
14
14
  // completion notice through this event, as a prompt the human never typed, so
15
15
  // a notice ticks nothing, keeps the grant and the mark, and clears only the
16
- // ending `RELAY` holds from an earlier turn. When a step is in flight a typed
17
- // prompt also emits ONE `UserPromptSubmit` additionalContext line so a fresh
18
- // session (post-compaction, or a scripted `-p` turn where no skill prose is in
19
- // context) still knows the beat: a tangent is parked, not chased, and a
20
- // message that also asks something is answered before the park's block.
21
- // Guidance only: it blocks nothing. It ALWAYS exits 0 and any failure degrades
22
- // to no output, so neither the tick nor the emit can ever wedge prompting.
16
+ // ending `RELAY` holds from an earlier turn. A typed prompt also emits ONE
17
+ // `UserPromptSubmit` additionalContext line when there is a beat to keep, so a
18
+ // fresh session (post-compaction, or a scripted `-p` turn where no skill prose
19
+ // is in context) still knows it: mid-step, a tangent is parked, not chased;
20
+ // at a pause or a stop whose card has shown, the message is the human's reply,
21
+ // answered in the model's own words and closed on the one line `handoff`
22
+ // prints, never on the card again (D110 (reply-after-the-card)). Guidance
23
+ // only: it blocks nothing. It ALWAYS exits 0 and any failure degrades to no
24
+ // output, so neither the tick nor the emit can ever wedge prompting.
23
25
  import { readFileSync, writeFileSync } from 'node:fs';
24
26
  import { parseBuildRanges, readRunArgs, resolveRun } from "../lib/orient.js";
25
- import { clearRelay, findSessionRoot, inSpike, intentPath, notePrompt, readPrompt, readRun, setGrant, setRun, stepPath, turnPath, } from "../lib/sidecar.js";
27
+ import { activeBuild, clearRelay, findSessionRoot, inSpike, intentPath, notePrompt, readPause, readPrompt, readRun, setGrant, setRun, stepPath, turnPath, } from "../lib/sidecar.js";
26
28
  import { hasStep, normalizeStepList, readBuildArgs } from "../lib/steplist.js";
29
+ import { waitingCard } from "./handoff.js";
27
30
  /**
28
- * The hook entry point: apply the prompt, then emit the in-flight-step nudge
29
- * when it counted as a human turn.
31
+ * The hook entry point: apply the prompt, then emit the nudge when it counted
32
+ * as a human turn.
30
33
  *
34
+ * The session is the one the payload's `cwd` names when it names one, else
35
+ * the one the process stands in, the rule the `Stop` check follows too, so the
36
+ * turn that ticks and the turn that is judged are always the same session's.
31
37
  * Always returns 0: a broken turn must never wedge a prompt, so any failure is
32
38
  * swallowed and degrades to no output.
33
39
  */
34
40
  export function turn(cwd, _args) {
35
41
  try {
36
- if (applyTurn(cwd, readStdin())) {
37
- const context = stepInFlightContext(cwd);
42
+ const raw = readStdin();
43
+ const { cwd: named, prompt } = readPayload(raw);
44
+ const where = named !== null && findSessionRoot(named) !== null ? named : cwd;
45
+ if (applyTurn(where, raw)) {
46
+ const context = turnContext(where, prompt);
38
47
  if (context !== null)
39
48
  process.stdout.write(context);
40
49
  }
@@ -45,32 +54,102 @@ export function turn(cwd, _args) {
45
54
  return 0;
46
55
  }
47
56
  /**
48
- * The `UserPromptSubmit` additionalContext payload when a step is in flight, or null.
57
+ * The `UserPromptSubmit` additionalContext payload for the beat the session is
58
+ * on, or null when there is none to keep.
49
59
  *
50
- * A step is in flight when a STEP marker (the flat control file recording the
51
- * step between `build` and `checkpoint`) is present. Guidance only: it reminds a
52
- * fresh session that a tangent is a park (not an edit), that the park's own
53
- * block is the whole turn when the message is only a tangent, that a message
54
- * which also asks something is answered first with the block after the answer,
55
- * and how the step lands, since neither the build nor the park prose may be in
56
- * context after compaction or on a scripted turn.
57
- * Exported because an in-process test cannot read the hook's real stdout.
60
+ * Three beats, read off the control files. A step whose pause card has shown
61
+ * (STEP, and PAUSE naming that step's pause) is waiting for the human's call,
62
+ * so the message is the reply to it: answered or acted on in the model's own
63
+ * words, and closed on the one line `handoff` prints rather than the card
64
+ * again (D110 (reply-after-the-card)). A step in flight with no card yet is
65
+ * mid-build, where a tangent is a park and not an edit. A stop card waiting
66
+ * for its reply is the pause's beat at a stop. At a pause or a stop, a
67
+ * `prompt` asking to see the card (`show the pause`, the phrase the card's
68
+ * `expand` move teaches) gets the one instruction that matters for it: paste
69
+ * the `--full` output exactly as it printed, as the whole turn. Guidance only,
70
+ * since none of the build, run, or park prose may be in context after
71
+ * compaction or on a scripted turn. Exported because an in-process test cannot
72
+ * read the hook's real stdout.
58
73
  */
59
- export function stepInFlightContext(cwd) {
74
+ export function turnContext(cwd, prompt = '') {
60
75
  const root = findSessionRoot(cwd);
61
76
  if (root === null)
62
77
  return null;
78
+ const text = nudgeText(root, SHOW_REQUEST.test(prompt));
79
+ return text === null ? null : `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } })}\n`;
80
+ }
81
+ // A message asking to see the card again: `show the pause` and `show the stop`,
82
+ // the phrases the card's `expand` move teaches, and `show me the card` beside
83
+ // them. In a fresh session the model ran `--full` on that request and then
84
+ // retyped the card behind a line of its own, three runs in five of the
85
+ // 2026-10-27 eval, because nothing in its context said how to relay it.
86
+ const SHOW_REQUEST = /\bshow\s+(?:me\s+)?the\s+(?:pause|stop|card)\b/i;
87
+ /**
88
+ * The nudge's words for the beat the session is on, or null when it is on
89
+ * none: no step in flight and no stop card waiting. `showing` is a message
90
+ * that asks to see the card, which at a pause or a stop takes the show nudge.
91
+ */
92
+ function nudgeText(root, showing) {
63
93
  const step = inFlightStep(root);
64
- if (step === null)
94
+ if (step !== null) {
95
+ const shown = readPause(root);
96
+ if (shown?.kind !== 'pause' || shown.step !== step)
97
+ return parkNudge(step);
98
+ return showing ? showNudge(`step ${step}'s card`, 'plumbbob handoff --full') : pauseNudge(step);
99
+ }
100
+ const slug = activeBuild(root);
101
+ let intent = '';
102
+ try {
103
+ intent = readFileSync(intentPath(root, slug), 'utf8');
104
+ }
105
+ catch {
65
106
  return null;
66
- const text = `plumbbob: step ${step} is in flight — a new idea or tangent is a park, not an edit: ` +
107
+ }
108
+ const card = waitingCard(root, slug, intent);
109
+ if (card === null)
110
+ return null;
111
+ return showing
112
+ ? showNudge(`range ${card.range} of ${card.count}'s stop card`, 'plumbbob handoff --stop --full')
113
+ : stopNudge(card.range, card.count);
114
+ }
115
+ /**
116
+ * The nudge when the human asks to see a card again: the one command and how
117
+ * to relay what it prints, since the card is the whole turn and a retyped one
118
+ * is the miss the relay check holds.
119
+ */
120
+ function showNudge(card, command) {
121
+ return (`plumbbob: the human asked to see ${card} again. ` +
122
+ `Run \`${command}\` and paste its output exactly as it printed, as the whole turn: ` +
123
+ `no line before it, nothing after it, and nothing in it retyped, reworded, or cut.`);
124
+ }
125
+ /** The mid-build nudge: a tangent is a park, not an edit, and the step lands on approval. */
126
+ function parkNudge(step) {
127
+ return (`plumbbob: step ${step} is in flight — a new idea or tangent is a park, not an edit: ` +
67
128
  `capture it with \`plumbbob park "<one line>"\` and relay the block it prints whole, ` +
68
129
  `with nothing written around it, since that block is the whole turn. ` +
69
130
  `The one exception is a message that also asks something: answer the question first, ` +
70
131
  `then relay the park's block after the answer and write nothing after it. ` +
71
132
  `The block's pointer back at the step closes the turn either way, so run no \`plumbbob handoff\` after it. ` +
72
- `The step stays open and lands when you run \`plumbbob checkpoint ${step}\` after approval.`;
73
- return `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } })}\n`;
133
+ `The step stays open and lands when you run \`plumbbob checkpoint ${step}\` after approval.`);
134
+ }
135
+ /** The nudge at a pause whose card has shown: the message is the reply, and the card is not printed again. */
136
+ function pauseNudge(step) {
137
+ return (`plumbbob: step ${step}'s card has shown, and this message is the human's reply to it. ` +
138
+ `An approval lands the step: run \`plumbbob checkpoint ${step}\` and relay its ending. ` +
139
+ `Anything else is answered in your own words: answer the question, or make the change it asks for, ` +
140
+ `and when a change moves what the card said, write \`.plumbbob/detail.md\` again so the record matches what lands. ` +
141
+ `Print no card for it: run \`plumbbob handoff\` and end the turn on the one line it prints, with nothing after it. ` +
142
+ `Only when the human asks to see the card again, run \`plumbbob handoff --full\` and paste its output exactly as it printed, as the whole turn. ` +
143
+ `A tangent the human hands you is still a park: \`plumbbob park "<one line>"\`.`);
144
+ }
145
+ /** The nudge at a stop card waiting for its reply: the stop's version of the pause's. */
146
+ function stopNudge(range, count) {
147
+ return (`plumbbob: range ${range} of ${count}'s stop card has shown, and this message is the human's reply to it. ` +
148
+ `An approval records the stop: fold each confirmed blocker into the plan, then run \`plumbbob harvest\` and relay its ending. ` +
149
+ `Anything else is answered in your own words: answer the question, or make the change it asks for ` +
150
+ `(a lean is its line under the detail file's \`## Harvest\`). ` +
151
+ `Print no card for it: run \`plumbbob handoff --stop\` and end the turn on the one line it prints, with nothing after it. ` +
152
+ `Only when the human asks to see the card again, run \`plumbbob handoff --stop --full\` and paste its output exactly as it printed, as the whole turn.`);
74
153
  }
75
154
  /**
76
155
  * The in-flight step number, or null when no STEP marker is present.
@@ -251,13 +330,13 @@ function readBuild(root) {
251
330
  }
252
331
  }
253
332
  /**
254
- * The `prompt` and `prompt_id` fields of the UserPromptSubmit JSON, as '' and
255
- * null when either is absent.
333
+ * The `prompt`, `prompt_id`, and `cwd` fields of the UserPromptSubmit JSON, as
334
+ * '' and null when any is absent.
256
335
  *
257
336
  * Absent, unparseable, or non-string input contributes nothing rather than
258
337
  * wedging the tool: the tick still lands (a human turn did occur), the empty
259
- * prompt clears any GRANT, and a missing id records none; the verb never
260
- * throws over bad stdin.
338
+ * prompt clears any GRANT, a missing id records none, and a missing `cwd`
339
+ * leaves the process's; the verb never throws over bad stdin.
261
340
  */
262
341
  function readPayload(raw) {
263
342
  try {
@@ -265,10 +344,11 @@ function readPayload(raw) {
265
344
  return {
266
345
  prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
267
346
  promptId: typeof parsed.prompt_id === 'string' && parsed.prompt_id !== '' ? parsed.prompt_id : null,
347
+ cwd: typeof parsed.cwd === 'string' && parsed.cwd !== '' ? parsed.cwd : null,
268
348
  };
269
349
  }
270
350
  catch {
271
- return { prompt: '', promptId: null };
351
+ return { prompt: '', promptId: null, cwd: null };
272
352
  }
273
353
  }
274
354
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "description": "Attention-first build process: 15 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",