plumbbob 0.13.6 → 0.13.8

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.
@@ -7,12 +7,14 @@
7
7
  // step it interrupted, so the relay is one command.
8
8
  // Capture is not a state transition, so it runs in any context (terminal or
9
9
  // in-session).
10
- import { readFileSync, writeFileSync } from 'node:fs';
10
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { findRepoRoot } from "../lib/git.js";
12
- import { activeBuild, hasSession, buildLogPath } from "../lib/sidecar.js";
12
+ import { activeBuild, hasSession, buildLogPath, intentPath } from "../lib/sidecar.js";
13
13
  import { appendToSection } from "../lib/buildlog.js";
14
14
  import { ending, notice, printEnding, transition } from "../lib/notice.js";
15
+ import { parseOpenQuestionRows } from "../lib/orient.js";
15
16
  import { driverPointer } from "./handoff.js";
17
+ import { setAsideAdvisories } from "./defer.js";
16
18
  /**
17
19
  * Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
18
20
  *
@@ -41,12 +43,20 @@ export function park(cwd, args) {
41
43
  return 1;
42
44
  }
43
45
  writeFileSync(path, updated);
46
+ const intent = intentPath(root);
47
+ const questions = parseOpenQuestionRows(existsSync(intent) ? readFileSync(intent, 'utf8') : '');
44
48
  // A park interrupts a step without ending it, so the ending is the capture
45
- // and the pointer back at what was interrupted.
49
+ // and the pointer back at what was interrupted. A park naming a question
50
+ // that still waits on the human says so, since the park list never moves
51
+ // the card's count (D123 (defer-records)).
46
52
  printEnding(root, {
47
53
  tier: 'driver',
48
54
  render: 'park',
49
- text: ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }),
55
+ text: ending({
56
+ lead: transition({ label: 'Parked', fact: text }),
57
+ advisories: setAsideAdvisories(text, questions),
58
+ pointer: driverPointer(root, activeBuild(root)),
59
+ }),
50
60
  });
51
61
  return 0;
52
62
  }
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import { findRepoRoot } from "../lib/git.js";
21
21
  import { parseBuildOrder, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding } from "../lib/orient.js";
22
- import { activeBuild, checkpointsPath, clearHandoff, clearTick, grantPath, handoffPath, hasSession, intentPath, inSpike, listBuilds, readTurn, setActiveBuild, setGrant, stepPath, tickPath, } from "../lib/sidecar.js";
22
+ import { activeBuild, clearHandoff, clearTick, grantPath, handoffPath, hasSession, intentPath, inSpike, listBuilds, planCommitted, readTurn, setActiveBuild, setGrant, stepPath, tickPath, } from "../lib/sidecar.js";
23
23
  import { spikeBranches, spikeWorktrees } from "./spike.js";
24
24
  /**
25
25
  * Report every inconsistency in the control plane, and with `--fix` repair the
@@ -255,7 +255,7 @@ function leftoverFindings(root, slug, stepInFlight) {
255
255
  // pause, and cleared by the checkpoint that lands either one. At the boundary
256
256
  // with the plan already committed, neither pause is open, so a TICK left here
257
257
  // is measuring a span that closed.
258
- const planLanded = readOr(checkpointsPath(root, slug)).split('\n').some((l) => l.startsWith('plan '));
258
+ const planLanded = planCommitted(root, slug);
259
259
  if (!stepInFlight && planLanded && existsSync(tickPath(root, slug))) {
260
260
  findings.push({
261
261
  name: 'latch tick',
@@ -29,6 +29,14 @@
29
29
  // stop card is a miss however whole the boundary was, since the card is the
30
30
  // run's ending, and its reason names `plumbbob handoff --stop` as the retry.
31
31
  //
32
+ // A reminder can close the turn and still leave the human without the reply.
33
+ // When the reply was written before a tool call and the final message is the
34
+ // reminder alone, a client that folds text written ahead of a tool call shows
35
+ // the line and nothing else (GitHub issue #12). That shape is held once too,
36
+ // by a reason that asks for the reply again and ends on the line to type. It
37
+ // names no command, since a retry that ran one would bury the reply the same
38
+ // way (D121 (reply-after-the-tools)).
39
+ //
32
40
  // The final message comes from the payload's `last_assistant_message`, never
33
41
  // from the transcript, which does not yet hold that message when Stop fires;
34
42
  // the transcript supplies only the turn's earlier messages, which an
@@ -79,13 +87,53 @@ export function relayCheck(cwd, raw) {
79
87
  // relayed: the card is the run's ending, and `handoff --stop` overwrites
80
88
  // this record once it prints.
81
89
  const carried = record.owes !== undefined ? false : carriedEnding(record, payload.last_assistant_message, payload);
90
+ if (carried === false)
91
+ return hold(root, record, reason(record));
92
+ // The reminder closed the turn, but the reply stood above a tool call.
93
+ if (carried === true && buriedReply(record, payload.last_assistant_message, payload))
94
+ return hold(root, record, buriedReason(record));
82
95
  // Whole, or not this check's to judge: let the turn end.
83
- if (carried !== false)
84
- return null;
96
+ return null;
97
+ }
98
+ /**
99
+ * Stamp the once in `RELAY` and emit the hold with its reason, or nothing when
100
+ * the stamp cannot land.
101
+ */
102
+ function hold(root, record, text) {
85
103
  // A hold that cannot be counted could repeat, so it is no hold at all.
86
104
  if (!holdRelay(root, record))
87
105
  return null;
88
- return `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: reason(record) } })}\n`;
106
+ return `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: text } })}\n`;
107
+ }
108
+ // The renders a reply at a card already shown closes on: the one line that
109
+ // points back at the pause or the stop.
110
+ const REMINDERS = new Set(['reminder', 'stop-reminder']);
111
+ /**
112
+ * Whether a reminder that closed the turn left the reply above a tool call:
113
+ * the final message is the reminder alone, with no words above its label,
114
+ * while the turn's earlier messages carried words. The printed line itself is
115
+ * not counted as words, since the transcript can already hold the final
116
+ * message when Stop fires. A transcript the check cannot read or scope is no
117
+ * evidence, and neither is a turn whose earlier messages said nothing.
118
+ */
119
+ function buriedReply(record, last, payload) {
120
+ if (!REMINDERS.has(record.render) || leadOf(last).trim().length > 0)
121
+ return false;
122
+ const earlier = turnText(payload);
123
+ if (earlier === null)
124
+ return false;
125
+ return earlier.split(record.text.trim()).join('').trim().length > 0;
126
+ }
127
+ /**
128
+ * The one-line reason for a reply buried above a tool call: what happened, and
129
+ * the line to end on, with no command to run, since a retry that ran one would
130
+ * leave the reply above a tool call again.
131
+ */
132
+ function buriedReason(record) {
133
+ return (`plumbbob: the turn ended on the ${DECISION_NAMES[record.render] ?? 'pause reminder'} it printed, ` +
134
+ 'but the reply stood above a tool call, where a client can fold it out of sight. ' +
135
+ 'Write the reply again, in your own words, with no tool call after it, ' +
136
+ `and end the turn on the same line, typed as it printed: ${record.text.trim()}`);
89
137
  }
90
138
  /**
91
139
  * Whether the turn carried the recorded ending: true when it did, false when
@@ -197,7 +245,27 @@ function blockCount(part) {
197
245
  * runs to the next. Text before the first label is not part of any.
198
246
  */
199
247
  function partsOf(text) {
248
+ return readEnding(text).parts;
249
+ }
250
+ /** The text above an ending's first labeled part: the words a reply wrote above the line it closes on. */
251
+ function leadOf(text) {
252
+ return readEnding(text).lead.join('\n');
253
+ }
254
+ /**
255
+ * One walk over a stretch of text: the lines before its first labeled part,
256
+ * and the parts themselves, each run to the next, with a fence read as one
257
+ * stretch so a label inside it opens nothing.
258
+ */
259
+ function readEnding(text) {
260
+ const lead = [];
200
261
  const parts = [];
262
+ const keep = (line) => {
263
+ const last = parts.at(-1);
264
+ if (last === undefined)
265
+ lead.push(line);
266
+ else
267
+ last.lines.push(line);
268
+ };
201
269
  let rail = null;
202
270
  for (const line of text.split('\n')) {
203
271
  const fence = /^\s*(`{3,})/.exec(line)?.[1];
@@ -206,7 +274,7 @@ function partsOf(text) {
206
274
  }
207
275
  else if (rail !== null && fence !== undefined && fence.length >= rail.length && line.trim() === fence) {
208
276
  rail = null;
209
- parts.at(-1)?.lines.push(line);
277
+ keep(line);
210
278
  continue;
211
279
  }
212
280
  const label = rail === null ? labelOf(line) : undefined;
@@ -214,10 +282,10 @@ function partsOf(text) {
214
282
  parts.push({ label, lines: [line] });
215
283
  }
216
284
  else {
217
- parts.at(-1)?.lines.push(line);
285
+ keep(line);
218
286
  }
219
287
  }
220
- return parts;
288
+ return { lead, parts };
221
289
  }
222
290
  /**
223
291
  * The label a line opens a part on, or undefined when it opens none: a bold
@@ -7,8 +7,8 @@ import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { detectGate } from "../lib/check.js";
9
9
  import { readTemplate, stampTemplate } from "../lib/templates.js";
10
- import { findRepoRoot, hasCommit, headSha, isDirty, isIgnored } from "../lib/git.js";
11
- import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
10
+ import { findRepoRoot, hasCommit, headSha, isDirty } from "../lib/git.js";
11
+ import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, buildFolderIgnored, planCommitted, setGrant, stampTick, } from "../lib/sidecar.js";
12
12
  import { settingsPath } from "../lib/settings.js";
13
13
  import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
14
14
  /**
@@ -41,6 +41,12 @@ export async function start(cwd, args) {
41
41
  // Every advisory the session accrues on its way open, collected rather than
42
42
  // printed: they belong inside the one ending below, in the order found.
43
43
  const advisories = [];
44
+ // `--local` on a session already open is the plan card's way out: keep this
45
+ // build untracked before its plan commit lands. It needs no title.
46
+ const open = local ? findRepoRoot(cwd) : null;
47
+ if (open !== null && hasSession(open)) {
48
+ return keepLocal(open);
49
+ }
44
50
  if (title.length === 0) {
45
51
  process.stderr.write(notice({ fact: 'start needs a title', remedy: 'plumbbob start "what you are building"' }));
46
52
  return 1;
@@ -154,7 +160,7 @@ export async function start(cwd, args) {
154
160
  // excludes the whole sidecar by design and says so, so skip the probe there.
155
161
  // Guidance only, and the session is already open: a probe hiccup stays silent
156
162
  // rather than half-crashing a scaffolded session.
157
- if (!local && sidecarIsIgnored(root, slug)) {
163
+ if (!local && buildFolderIgnored(root, slug)) {
158
164
  advisories.push(advisory({
159
165
  fact: 'this repo gitignores .plumbbob/',
160
166
  detail: ['the build folder cannot ride the branch', 'plan and finish commits will be record-only'],
@@ -210,20 +216,40 @@ function datedSlug(title) {
210
216
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${base}`;
211
217
  }
212
218
  /**
213
- * Whether the tracked build folder is gitignored: the record-only advisory's
214
- * predicate, probing exactly the path `checkpoint --plan` will try to stage.
219
+ * Keep an open session untracked: `plumbbob start --local` typed while a build
220
+ * is open and its plan not yet committed, the way out the plan card offers
221
+ * (D117 (publish-on-the-card)).
215
222
  *
216
- * Best-effort: a probe that throws (a fatal `git check-ignore`) reads as
217
- * not-ignored so the advisory stays silent rather than crashing a session that
218
- * is already open.
223
+ * It writes the blanket `.plumbbob/` line to this clone's info/exclude, the
224
+ * line a `--local` start writes, so the plan and finish commits land
225
+ * record-only and nothing under `.plumbbob/` rides the branch. The folder
226
+ * keeps its place and its slug; only git's view of it changes. Refuses once the
227
+ * plan has landed, since the folder is in a commit by then and an exclude
228
+ * cannot take it back.
219
229
  */
220
- function sidecarIsIgnored(root, slug) {
221
- try {
222
- return isIgnored(root, buildDir(root, slug));
223
- }
224
- catch {
225
- return false;
230
+ function keepLocal(root) {
231
+ if (planCommitted(root)) {
232
+ process.stderr.write(notice({
233
+ fact: 'this session can no longer be kept local',
234
+ detail: ['its plan is already committed with the build folder'],
235
+ remedy: 'plumbbob finish to close it before starting another',
236
+ }));
237
+ return 1;
226
238
  }
239
+ excludeSidecar(root);
240
+ printEnding(root, {
241
+ tier: 'driver',
242
+ render: 'start',
243
+ text: ending({
244
+ lead: transition({
245
+ label: 'Session',
246
+ fact: 'kept local',
247
+ detail: ['.plumbbob/ excluded in this clone', 'the plan and finish commits land record-only'],
248
+ remedy: 'plumbbob handoff --plan to show the plan card again',
249
+ }),
250
+ }),
251
+ });
252
+ return 0;
227
253
  }
228
254
  /**
229
255
  * Read the value that follows a `--flag` in argv (for example `--slug my-build`), or
@@ -8,14 +8,15 @@
8
8
  // `--run`, it prints the run a `/plumbbob:run` invocation names above the
9
9
  // dashboard, and names the waiting card again beneath the run's grant.
10
10
  import { readFileSync } from 'node:fs';
11
- import { join } from 'node:path';
11
+ import { join, relative } from 'node:path';
12
12
  import { PLAN_COMMIT_MARKER, commitsSince, findRepoRoot } from "../lib/git.js";
13
13
  import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
14
14
  import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
15
15
  import { readGrant } from "../lib/latch.js";
16
- import { formatOrientation, formatRunDashboard, lastLedgerSha, orient, parseRequestedStep, resolveRunArgs, } from "../lib/orient.js";
16
+ import { formatOrientation, formatRunDashboard, lastLedgerSha, orient, parseOpenQuestionRows, parseRequestedStep, resolveRunArgs, } from "../lib/orient.js";
17
17
  import { normalizeStepList } from "../lib/steplist.js";
18
- import { waitingCard } from "./handoff.js";
18
+ import { blocks, notice } from "../lib/notice.js";
19
+ import { questionsPart, waitingCard } from "./handoff.js";
19
20
  /**
20
21
  * Read a file as utf8, or '' when it is missing or unreadable.
21
22
  */
@@ -67,7 +68,10 @@ function grantText(grant) {
67
68
  * With `--run "<raw args>"` (how the `/plumbbob:run` skill passes its own),
68
69
  * the dashboard prints beneath a header naming the run the text resolves to
69
70
  * and the grant the turn hook minted, or carries the refusal in its pointer
70
- * when the run names nothing it can walk.
71
+ * when the run names nothing it can walk. With `--questions` it prints the
72
+ * plan's open questions as the plan card tables them, and nothing else: the
73
+ * ones that wait on the human in the table, and the ones deferred to the work
74
+ * on a line beneath it.
71
75
  */
72
76
  export function status(cwd, args = []) {
73
77
  const root = findRepoRoot(cwd);
@@ -88,6 +92,14 @@ export function status(cwd, args = []) {
88
92
  return 0;
89
93
  }
90
94
  }
95
+ if (rest.includes('--questions')) {
96
+ // The open questions alone, as the plan card tables them: the view a skill
97
+ // relays when the human asks what is still open (D118 (questions-table)),
98
+ // with the deferred ones on their own line (D120 (deferred-questions)).
99
+ const part = questionsPart(parseOpenQuestionRows(readOr(intentPath(root, slug))), relative(root, intentPath(root, slug)));
100
+ process.stdout.write(part === null ? notice({ fact: 'no open questions in this plan' }) : blocks([part]));
101
+ return 0;
102
+ }
91
103
  const inFlightRaw = readOr(stepPath(root, slug)).trim();
92
104
  // Out-of-band commits (ones that landed outside the checkpoints ledger) are
93
105
  // surfaced here, never blocked. Counting them needs git (orient is pure), so
@@ -19,7 +19,10 @@
19
19
  // is in context) still knows it: mid-step, a tangent is parked, not chased;
20
20
  // at a pause or a stop whose card has shown, the message is the human's reply,
21
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
22
+ // prints, never on the card again (D110 (reply-after-the-card)). Every tool
23
+ // call comes before that answer and none after it, since a client that folds
24
+ // text written ahead of a tool call would show the line and hide the answer
25
+ // (D121 (reply-after-the-tools)). Guidance
23
26
  // only: it blocks nothing. It ALWAYS exits 0 and any failure degrades to no
24
27
  // output, so neither the tick nor the emit can ever wedge prompting.
25
28
  import { readFileSync, writeFileSync } from 'node:fs';
@@ -60,8 +63,9 @@ export function turn(cwd, _args) {
60
63
  * Three beats, read off the control files. A step whose pause card has shown
61
64
  * (STEP, and PAUSE naming that step's pause) is waiting for the human's call,
62
65
  * 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
66
+ * words after every tool call, and closed on the one line `handoff` prints
67
+ * rather than the card again (D110 (reply-after-the-card),
68
+ * D121 (reply-after-the-tools)). A step in flight with no card yet is
65
69
  * mid-build, where a tangent is a park and not an edit. A stop card waiting
66
70
  * for its reply is the pause's beat at a stop. At a pause or a stop, a
67
71
  * `prompt` asking to see the card (`show the pause`, the phrase the card's
@@ -127,18 +131,23 @@ function parkNudge(step) {
127
131
  return (`plumbbob: step ${step} is in flight — a new idea or tangent is a park, not an edit: ` +
128
132
  `capture it with \`plumbbob park "<one line>"\` and relay the block it prints whole, ` +
129
133
  `with nothing written around it, since that block is the whole turn. ` +
130
- `The one exception is a message that also asks something: answer the question first, ` +
131
- `then relay the park's block after the answer and write nothing after it. ` +
134
+ `The one exception is a message that also asks something: run \`plumbbob park\` first, then answer the question, ` +
135
+ `then end the turn on the park's block, typed as it printed, with no tool call after the answer. ` +
132
136
  `The block's pointer back at the step closes the turn either way, so run no \`plumbbob handoff\` after it. ` +
133
137
  `The step stays open and lands when you run \`plumbbob checkpoint ${step}\` after approval.`);
134
138
  }
135
- /** The nudge at a pause whose card has shown: the message is the reply, and the card is not printed again. */
139
+ /**
140
+ * The nudge at a pause whose card has shown: the message is the reply, every
141
+ * tool call comes before it, and the card is not printed again.
142
+ */
136
143
  function pauseNudge(step) {
137
144
  return (`plumbbob: step ${step}'s card has shown, and this message is the human's reply to it. ` +
138
145
  `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. ` +
146
+ `Anything else is answered in your own words, after every tool call and with none after it: ` +
147
+ `first read what you need and make the change it asks for, ` +
148
+ `write \`.plumbbob/detail.md\` again when a change moves what the card said, so the record matches what lands, ` +
149
+ `and run \`plumbbob handoff\` last among them, keeping the one line it prints. ` +
150
+ REPLY_LAST +
142
151
  `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
152
  `A tangent the human hands you is still a park: \`plumbbob park "<one line>"\`.`);
144
153
  }
@@ -146,11 +155,19 @@ function pauseNudge(step) {
146
155
  function stopNudge(range, count) {
147
156
  return (`plumbbob: range ${range} of ${count}'s stop card has shown, and this message is the human's reply to it. ` +
148
157
  `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. ` +
158
+ `Anything else is answered in your own words, after every tool call and with none after it: ` +
159
+ `first read what you need and make the change it asks for (a lean is its line under the detail file's \`## Harvest\`), ` +
160
+ `and run \`plumbbob handoff --stop\` last among them, keeping the one line it prints. ` +
161
+ REPLY_LAST +
152
162
  `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.`);
153
163
  }
164
+ // How a reply at a card already shown ends, the same at a pause and a stop: the
165
+ // answer after the last tool call, then the line, because a client that folds
166
+ // text written before a tool call shows the line and hides the answer
167
+ // (D121 (reply-after-the-tools)).
168
+ const REPLY_LAST = `Then write your answer and end the turn on that line, typed as it printed, with no tool call after your answer: ` +
169
+ `a client that folds text written before a tool call would hide the answer and show the line alone. ` +
170
+ `Print no card for it. `;
154
171
  /**
155
172
  * The in-flight step number, or null when no STEP marker is present.
156
173
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.13.6",
3
+ "version": "0.13.8",
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",
@@ -3,7 +3,7 @@ name: build
3
3
  description: "The default engine: read the next planned step from intent, implement it (its done-when, seam, Decisions, Constraints), then verify it through to the approval pause. Swappable: build by hand/vibed/another harness and run /plumbbob:verify instead. `--auto` self-approves and chains to done; a step range like `1-3`, or a list like `20,5-9`, self-approves exactly the steps it names, in build order, then stops."
4
4
  argument-hint: "[step-number | step-range | step-list] [--auto]"
5
5
  disable-model-invocation: true
6
- allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob build:*), Bash(plumbbob handoff:*), Bash(plumbbob check:*), Bash(plumbbob checkpoint:*), Bash(plumbbob park:*), Bash(plumbbob agent:*), Bash(plumbbob spike:*), Bash(git diff:*)
6
+ allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob build:*), Bash(plumbbob handoff:*), Bash(plumbbob check:*), Bash(plumbbob checkpoint:*), Bash(plumbbob park:*), Bash(plumbbob defer:*), Bash(plumbbob agent:*), Bash(plumbbob spike:*), Bash(git diff:*)
7
7
  ---
8
8
 
9
9
  # PlumbBob: build a step (the default engine)
@@ -66,7 +66,13 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
66
66
  capture it by running `plumbbob park "<one tidy line>"`: **saying "let's defer
67
67
  that" writes nothing; only the park line in the build log is a capture.** When the
68
68
  *human* hands you the tangent mid-build, their message **is** the approval to park
69
- it; capture it right away, say what you parked, and stay on the step. If you
69
+ it; capture it right away, say what you parked, and stay on the step. An open question
70
+ the human sets aside to wait on other work is deferred, not parked: run
71
+ `plumbbob defer Q3 "<what settles it>"`, because the card counts questions by the
72
+ marker on each opener and never by the park list
73
+ ([D123 (defer-records)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d123)).
74
+ When `plumbbob park` warns that a question it names still counts as one to answer,
75
+ defer it if the human said the work will settle it, and leave it to them otherwise. If you
70
76
  genuinely cannot finish without touching more than the seam, that is scope drift:
71
77
  surface it to the human rather than sprawling.
72
78
  - **Bound agents can shape the build.** If `plumbbob status` shows a
@@ -186,7 +192,9 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
186
192
  of its own, one command to a line, which handoff keeps line for line. A slash command
187
193
  or a path stays in backticks in the sentence. `checkpoint` records this file beneath the step's dated
188
194
  line in the build-log's `## Log` and then truncates it, so the detail lands in the
189
- tracked ledger rather than piling up on disk.
195
+ tracked ledger rather than piling up on disk. Write a relative link from `.plumbbob/`, where
196
+ the file lives (`builds/<slug>/intent.md#d5`); the record re-aims it at the build folder.
197
+ A decision cited by its slug in plain text (`D5 (no-link-no-reconnect)`) needs no link at all.
190
198
 
191
199
  **A bare range's pause is its stop.** When the injected dashboard's ranges block lists
192
200
  this step as a bare range (`/plumbbob:build 5`, its row marked `▸`), the plan reserved a
@@ -200,8 +208,8 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
200
208
  Readout fence (its measured `check`, `seam`, `diff`, and `spent` rows folded with your
201
209
  three, the changed files listed beneath `diff`), a `diff` fence when the change is 20
202
210
  lines or fewer, Your Call, the state table (the Verdict folded worst-of from the same
203
- rows, the Now row naming the step in flight, Next Up, and the Range, Order, and Parked
204
- rows when they apply), your recommendation, and the `more details` link last. Run it
211
+ rows, the Now row naming the step in flight, Next Up with the next range on its second
212
+ line, and the Order and Parked rows when they apply), your recommendation, and the `more details` link last. Run it
205
213
  once: a second run at the same pause prints the one-line reminder instead of the card,
206
214
  so if that line comes back where the card belongs (you ran handoff earlier in this
207
215
  turn), run `plumbbob handoff --full` and paste that. The gate verdict's one home is that `check`
@@ -213,7 +221,7 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
213
221
  one, the write-versus-relay line of
214
222
  [D78 (em-dash-ban)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d78).
215
223
 
216
- *Then answer the reply in your own words.* The card shows once
224
+ *Then answer the reply in your own words, after every tool call.* The card shows once
217
225
  ([D110 (reply-after-the-card)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d110)):
218
226
  every later turn at this pause is your reply, closed by one line. A message that
219
227
  **asks** ("expand 2", "what does that mean?", "why did the seam row flag that?") is an
@@ -222,11 +230,16 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
222
230
  make the change, run the check, and say what moved and what the check found; when the
223
231
  change moves what the card said (a judgment row, the Summary, the recommendation), write
224
232
  the detail file again so the record matches what lands. Nothing lands until the human
225
- says `looks good`. Either way, then run `plumbbob handoff` and paste its output beneath
226
- your reply, whole and verbatim: at a pause whose card has shown it prints one line,
227
- `**Next Up**: Back to Step <N> of <M> - <title>`, whatever changed since, so your reply
228
- stays on screen. The turn is your reply, then that line, and nothing after it. The CLI
229
- picks the form, never you: paste whichever one it prints, and never retype the card.
233
+ says `looks good`. Either way, every tool call comes before your reply and none after
234
+ it ([D121 (reply-after-the-tools)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d121)):
235
+ read, edit, run the check, write the detail file, and run `plumbbob handoff` last among
236
+ them. At a pause whose card has shown it prints one line,
237
+ `**Next Up**: Back to **Step N** on <Model> - <title>`, whatever changed since. Keep
238
+ that line, then write your reply, and end the turn on the line, whole and verbatim. The
239
+ turn is your tool calls, then your reply, then that line, and nothing after it: a
240
+ client that folds text written before a tool call (Zed, through the agent bridge) shows
241
+ the line and hides a reply written above the call. The CLI picks the form, never you:
242
+ paste whichever one it prints, and never retype the card.
230
243
 
231
244
  The card comes back one way: the human asks to see it (`show the pause`, or any wording
232
245
  of it). Then run `plumbbob handoff --full` and paste its output whole, with nothing
@@ -238,12 +251,13 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
238
251
  **Then hand off with the next model.** `plumbbob checkpoint` prints the whole boundary
239
252
  ending itself: `**Checkpoint**: Step N complete (<sha>)`, any advisory (a staged path
240
253
  outside the seam is the common one), and the state table: the Verdict the step earned,
241
- the Stop row when this landing completes a range (`| Stop | Range 3 of 5 landed - <its
254
+ the Stop row when this landing completes a range (`| Stop | **Range 3** landed - <its
242
255
  stop> |`, the reason the build stops here), Next Up, and the rows beneath it. Relay that
243
256
  block whole, the table with the rest (the relay check holds a boundary that dropped a
244
257
  row), run no second command, and write nothing around it. A later turn at this boundary
245
- is your reply; a `plumbbob handoff` there prints Next Up's one line, since the boundary
246
- has shown. It is the
258
+ is your reply, in the same order as at a pause: a `plumbbob handoff` there prints Next
259
+ Up's one line, since the boundary has shown, so run it last among your tool calls, then
260
+ write your reply and end on that line, with no tool call after it. It is the
247
261
  orientation tier (no Your Call block, no recommendation), and its Next Up points at the
248
262
  next undone step carrying that step's `- model:` recommendation (the plan's
249
263
  smallest-model-that-fits call) so the human knows which `/model` to select before
@@ -371,9 +385,13 @@ out, or the next step in build order is one the range does not name),
371
385
  - **A step inside the range won't build** (its seam won't parse): `plumbbob build <n>` fails
372
386
  exactly as it always does; surface it and stop, the same as a red check. Do not skip past
373
387
  the gap.
374
- - **The range starts above the next undone step**: you are jumping over earlier planned work;
375
- note that you are skipping the steps before it, then proceed (the same latitude as a
376
- single-number jump like `/plumbbob:build 4`).
388
+ - **The range starts above the next undone step** (the order reads `6, 4, 5` under
389
+ `/plumbbob:build 4-5`): the first step enters as a single-number jump like `/plumbbob:build 4`
390
+ would, with the skipped step noted. Once it lands, the next undone step in build order is
391
+ the one you jumped, which the range does not name, so the halt above fires there, and
392
+ `plumbbob build` names step 4 as the last undone step in the range, its detail naming step 6
393
+ as the step the walk halts before. The range that walks the whole stretch names the jumped
394
+ step too (`/plumbbob:build 6,4-5`).
377
395
  - **The build order puts a step the range does not name inside it** (the order reads
378
396
  `1, 4, 2, 3` under `/plumbbob:build 1-3`): the halt above fires before step 4, and
379
397
  `plumbbob build` names step 1 as the last undone step in the range. The range that walks
@@ -387,8 +405,8 @@ out, or the next step in build order is one the range does not name),
387
405
  `Parked` row, `2 of 7 open`), the move is `/plumbbob:harvest`, then the run the card names.
388
406
  - **After an early stop, settle the step, then rerun what is left.** A halt on trouble
389
407
  inside a range ends at that step's pause, and the grant ends with the turn. Once the
390
- human approves and the step lands, the state table's Range row names the run that lands
391
- what is left of the range (`| Range | 3 of 5: /plumbbob:run (steps 8-9) |`), and that is
408
+ human approves and the step lands, the range line beneath Next Up names the run that lands
409
+ what is left of the range (`| | **Range 3** on Opus: /plumbbob:run (steps 8-9) |`), and that is
392
410
  what the human types to carry on; `/plumbbob:build 8-9` lands the same steps by hand.
393
411
 
394
412
  ## The hard contracts
@@ -2,7 +2,7 @@
2
2
  name: harvest
3
3
  description: "Triage the park list at a step boundary: propose one class (blocker/tangent/pivot) for every open parked item at once, and once the human settles them in one reply, fold a confirmed blocker into intent and record the rest with plumbbob harvest. A run's stop card carries its own harvest."
4
4
  disable-model-invocation: true
5
- allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob order:*), Bash(plumbbob handoff:*), Bash(plumbbob harvest:*)
5
+ allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob order:*), Bash(plumbbob defer:*), Bash(plumbbob handoff:*), Bash(plumbbob harvest:*)
6
6
  ---
7
7
 
8
8
  # PlumbBob: harvest the park list
@@ -107,7 +107,16 @@ the call on each one theirs. The detail file you write first is the proposal; re
107
107
  The verb's ending names each range it re-cut, and an advisory names any it left for
108
108
  you; re-cut that one in the same confirmed edit, and `plumbbob status` afterward carries
109
109
  a `⚠` row for any range still broken.
110
- - Fold a confirmed blocker into `intent.md` before you run `plumbbob harvest`, so the
111
- record and the plan agree when it lands.
110
+ - A confirmed **tangent** that sets an open question aside (its park names `Q3`, and the
111
+ question waits on work still to come) defers that question as well: run
112
+ `plumbbob defer Q3 "<what settles it>"`. Say so in the proposal's reason
113
+ (`tangent: Q3 waits on step 9's numbers`), so the human's confirmation covers the
114
+ deferral. The park list never moves the Questions count, so a tangent alone leaves the
115
+ card reading a call still owed, and the record's ending warns of any question a tangent
116
+ names that still counts as one to answer
117
+ ([D123 (defer-records)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d123)).
118
+ - Fold a confirmed blocker into `intent.md`, and defer each question a confirmed tangent
119
+ sets aside, before you run `plumbbob harvest`, so the record and the plan agree when it
120
+ lands.
112
121
  - Never reclassify or resolve an item the human hasn't confirmed, and default every
113
122
  uncertain item to **tangent**, never to blocker.
@@ -4,7 +4,7 @@ description: "Compose one tidy tagged park line, get the human's OK in-turn, the
4
4
  argument-hint: "[idea]"
5
5
  disable-model-invocation: true
6
6
  model: haiku
7
- allowed-tools: Bash(plumbbob status:*), Bash(plumbbob park:*)
7
+ allowed-tools: Bash(plumbbob status:*), Bash(plumbbob park:*), Bash(plumbbob defer:*)
8
8
  ---
9
9
 
10
10
  # PlumbBob: park an idea (capture, don't chase)
@@ -47,19 +47,36 @@ Then:
47
47
  the capture plus that one pointer are the whole turn, and both are the CLI's to render,
48
48
  never yours to compose from the dashboard. The capture is a one-beat interruption; the
49
49
  step keeps its focus.
50
- 4. **A question beside the tangent is answered first.** When the human's message also
51
- asks something, answer the question, then relay the park's block after the answer,
52
- and write nothing after it. A message that is only a tangent ends on the block alone.
53
- Either way the block's pointer back at the step closes the turn, so run no
54
- `plumbbob handoff` after it. The answer is what the human asked for; dropping it to
55
- keep the block alone, or stacking a second ending under the block, are the two ways
50
+ 4. **A question beside the tangent is answered after the capture.** When the human's
51
+ message also asks something, run `plumbbob park` first, then answer the question, then
52
+ end the turn on the park's block, typed as it printed, with no tool call after the
53
+ answer ([D121 (reply-after-the-tools)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d121)):
54
+ a client that folds text written before a tool call would show the block and hide the
55
+ answer. A message that is only a tangent ends on the block alone. Either way the
56
+ block's pointer back at the step closes the turn, so run no `plumbbob handoff` after
57
+ it. The answer is what the human asked for; dropping it to keep the block alone,
58
+ writing it above the capture, or stacking a second ending under the block are the ways
56
59
  this goes wrong.
57
60
 
61
+ ## A question set aside is deferred, not parked
62
+
63
+ When the idea is an open question from the plan that the human wants picked up later,
64
+ once other work lands (`leave Q3 until the latency numbers are in`), run
65
+ `plumbbob defer Q3 "<what settles it>"` in place of the park. The question's own opener
66
+ is where it waits, and the card counts it apart from the questions to answer; a park line
67
+ beside it would leave the card counting it as a call still owed
68
+ ([D123 (defer-records)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d123)).
69
+ `plumbbob defer` prints the same kind of block, `**Deferred**: Q3 (<slug>) until <what
70
+ settles it>` and the pointer, and you relay it the same way. When `plumbbob park` warns that
71
+ a question it names still counts as one to answer, relay the block as printed; whether to
72
+ defer it is the human's call.
73
+
58
74
  ## The one hard contract
59
75
 
60
76
  The capture itself is the **dumb CLI**, never an edit. This skill carries **no Edit and
61
- no Write tool** on purpose: you may not append to `build-log.md` yourself. Compose, get
62
- approval, then shell `plumbbob park`; that is the only write path. If approval
77
+ no Write tool** on purpose: you may not append to `build-log.md` yourself, nor mark a
78
+ question in `intent.md`. Compose, get approval, then shell `plumbbob park`, or
79
+ `plumbbob defer` for a question set aside; those are the only write paths. If approval
63
80
  never comes, capture nothing. (When the human's **own message** states the idea, a
64
81
  tangent they raise mid-build, that message **is** the approval: capture it directly
65
82
  with `plumbbob park`, don't ask again. Deferring in words without the CLI line captures