plumbbob 0.13.7 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.13.7",
4
+ "version": "0.13.8",
5
5
  "description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
package/README.md CHANGED
@@ -177,7 +177,7 @@ In Claude Code, inside any git repo with a clean tree:
177
177
  | --- | --- |
178
178
  | Now | **Step 1** on Opus - feat(limiter): add a token-bucket limiter |
179
179
  | Next Up | **Step 2** on Sonnet - feat(login): wire the limiter into POST /login (details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28)) |
180
- | Range | **1** lands with step 1 |
180
+ | | **Range 2** on Sonnet: /plumbbob:run once step 1 lands |
181
181
 
182
182
  **Recommendation**: Approve it, refilling on read. The gate is green and the seam held, and a bucket that refills when it is read needs no timer for the route to clean up.
183
183
 
@@ -192,7 +192,7 @@ In Claude Code, inside any git repo with a clean tree:
192
192
  `.plumbbob/settings.json`, for example `"check": "npm test"`; in a repo where
193
193
  checkride finds no tools at all, `start` says so up front and names that key.)
194
194
  3. **Reply.** Say `looks good` and it commits the step as a checkpoint, marks it done,
195
- and returns to the boundary, where the Range row beneath Next Up names the run to type
195
+ and returns to the boundary, where the range line beneath Next Up names the run to type
196
196
  next and the steps it will land:
197
197
 
198
198
  ```text
@@ -201,7 +201,7 @@ In Claude Code, inside any git repo with a clean tree:
201
201
  | **Verdict** | ● Plumb |
202
202
  | --- | --- |
203
203
  | Next Up | **Step 2** on Sonnet - feat(login): wire the limiter into POST /login (details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28)) |
204
- | Range | **2** on Sonnet: /plumbbob:run (steps 2-3) |
204
+ | | **Range 2** on Sonnet: /plumbbob:run (steps 2-3) |
205
205
  ```
206
206
 
207
207
  ![The boundary, drawn: a bench mark freshly cut into the newest course of a stone wall, older marks below, and the red line raised one course over stones not yet laid](site/media/checkpoint-boundary.svg)
package/dist/cli-core.js CHANGED
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url';
14
14
  import { start } from "./verbs/start.js";
15
15
  import { status } from "./verbs/status.js";
16
16
  import { park } from "./verbs/park.js";
17
+ import { defer } from "./verbs/defer.js";
17
18
  import { harvest } from "./verbs/harvest.js";
18
19
  import { order } from "./verbs/order.js";
19
20
  import { build } from "./verbs/build.js";
@@ -152,6 +153,16 @@ const VERBS = [
152
153
  tolerateUnknownFlags: true,
153
154
  notes: 'Refuses (exit 1) with no session, empty text, or no `## Park list` section.',
154
155
  },
156
+ {
157
+ name: 'defer',
158
+ description: 'leave an open question to the work, until what will settle it',
159
+ synopsis: ['defer <Qn> "<what settles it>"'],
160
+ args: [
161
+ { name: '<Qn>', gloss: 'the open question, by its handle (Q3)' },
162
+ { name: '<what settles it>', gloss: 'the work the question waits on (free text; quote it)' },
163
+ ],
164
+ notes: "Writes `*deferred:* <date>, until <what settles it>` on the question's opener in intent.md, in place of its `*resolve by:*` tag or an earlier deferral, so every count reads it apart from the questions to answer. Refuses (exit 1) with no session, no handle, no until-clause, a handle the open questions lack, or a question already resolved.",
165
+ },
155
166
  {
156
167
  name: 'order',
157
168
  description: 'set the build order: the undone steps in the sequence to build them',
@@ -378,6 +389,8 @@ async function dispatch(verb, cwd, rest) {
378
389
  return status(cwd, rest);
379
390
  case 'park':
380
391
  return park(cwd, rest);
392
+ case 'defer':
393
+ return defer(cwd, rest);
381
394
  case 'order':
382
395
  return order(cwd, rest);
383
396
  case 'harvest':
@@ -1221,11 +1221,49 @@ function deferredText(body) {
1221
1221
  .replace(/^\d{4}-\d{2}-\d{2},?\s*/, '')
1222
1222
  .trim();
1223
1223
  }
1224
+ /**
1225
+ * Defer an open question to the work: on its opener line, `*deferred:* <date>,
1226
+ * until <what settles it>` takes the place of its `*resolve by:*` tag, or of
1227
+ * an earlier `*deferred:*` marker, or joins the end of a line that has
1228
+ * neither. The until-clause is folded onto one line, since the parser reads
1229
+ * the opener line alone (D120 (deferred-questions)).
1230
+ *
1231
+ * `resolved` names an opener the plan closed, `missing` one the plan lacks or
1232
+ * has not filled in, with the handles still open beside it.
1233
+ */
1234
+ export function deferQuestion(intent, id, date, until) {
1235
+ const rows = parseOpenQuestionRows(intent);
1236
+ const row = rows.find((q) => q.id === id);
1237
+ if (row === undefined) {
1238
+ const closed = sectionLines(intent, '## Open questions').lines.some((raw) => {
1239
+ const opener = QUESTION_OPENER.exec(raw.trim());
1240
+ return opener?.[1] === id && resolvedOpener(raw);
1241
+ });
1242
+ return closed ? { kind: 'resolved' } : { kind: 'missing', open: rows.map((q) => q.id) };
1243
+ }
1244
+ const lines = intent.split('\n');
1245
+ const raw = lines[row.line - 1] ?? '';
1246
+ const marker = `*deferred:* ${date}, until ${until.replace(/\s*\n\s*/g, ' ').trim()}`;
1247
+ const tag = raw.search(/\*(?:resolve by|deferred):\*/i);
1248
+ lines[row.line - 1] = tag === -1 ? `${raw.trimEnd()}, ${marker}` : `${raw.slice(0, tag).trimEnd()} ${marker}`;
1249
+ const updated = lines.join('\n');
1250
+ return { kind: 'deferred', intent: updated, question: parseOpenQuestionRows(updated).find((q) => q.id === id) ?? row };
1251
+ }
1224
1252
  /** Split open question rows by who acts on them (D120 (deferred-questions)). */
1225
1253
  export function questionTotals(rows) {
1226
1254
  const deferred = rows.filter((q) => q.deferred !== null).length;
1227
1255
  return { toAnswer: rows.length - deferred, deferred };
1228
1256
  }
1257
+ /**
1258
+ * The open questions still waiting on the human that a line of text names by
1259
+ * handle (`Q3`), in the order the plan lists them. A park or a tangent naming
1260
+ * one has set it aside without deferring it, so the count still reads it as a
1261
+ * call owed.
1262
+ */
1263
+ export function questionsToAnswerNamed(text, rows) {
1264
+ const named = new Set(text.match(/\bQ\d+\b/g) ?? []);
1265
+ return rows.filter((q) => q.deferred === null && named.has(q.id));
1266
+ }
1229
1267
  /**
1230
1268
  * The dashboard's question count, split the way the card's Questions row
1231
1269
  * splits it: `open questions 1 to answer, 2 deferred`, either half alone when
@@ -762,6 +762,7 @@ const RELAY_RENDERS = [
762
762
  'checkpoint',
763
763
  'plan-commit',
764
764
  'park',
765
+ 'defer',
765
766
  'revert',
766
767
  'abandon',
767
768
  'spike-open',
@@ -0,0 +1,96 @@
1
+ // `plumbbob defer <Qn> "<what settles it>"`: leave an open question to the
2
+ // work. The marker it writes on the question's opener in intent.md,
3
+ // `*deferred:* <date>, until <what settles it>`, is the one thing every count
4
+ // reads to tell a question that waits on the human from one that waits on
5
+ // the work (D120 (deferred-questions)). Until this verb the marker was a hand
6
+ // edit made only at the plan card, so a question set aside anywhere else (parked
7
+ // mid-step, or settled as a tangent at a harvest) kept counting as a call
8
+ // owed. The skill decides and the CLI records (D123 (defer-records)): deferring
9
+ // stays the human's call, and the verb only writes it, dated, in the one form
10
+ // the parser reads. Like park it changes no state the loop runs on, so it runs
11
+ // in any context.
12
+ import { readFileSync, writeFileSync } from 'node:fs';
13
+ import { findRepoRoot } from "../lib/git.js";
14
+ import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
15
+ import { deferQuestion, questionsToAnswerNamed } from "../lib/orient.js";
16
+ import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
17
+ import { driverPointer } from "./handoff.js";
18
+ // What every refusal about the arguments points at.
19
+ const USAGE = 'plumbbob defer Q3 "<what settles it>"';
20
+ /**
21
+ * Mark the named open question deferred until what the rest of the arguments
22
+ * say will settle it, and print the ending: the deferral and the pointer.
23
+ *
24
+ * A leading "until" in the text is dropped, since the marker writes its own.
25
+ * Refuses (exit 1) with no session, no question handle, no until-clause, a
26
+ * handle the open questions lack, or a question already resolved.
27
+ */
28
+ export function defer(cwd, args) {
29
+ const root = findRepoRoot(cwd);
30
+ if (root === null || !hasSession(root)) {
31
+ process.stderr.write(notice({ fact: 'no active session', detail: ['no open questions to defer'], remedy: 'plumbbob start "<title>"' }));
32
+ return 1;
33
+ }
34
+ const [handle = '', ...rest] = args.filter((a) => !a.startsWith('--'));
35
+ const id = /^Q(\d+)$/i.exec(handle.trim());
36
+ if (id === null) {
37
+ process.stderr.write(notice({ fact: 'defer needs a question', detail: ['its handle first, Q3'], remedy: USAGE }));
38
+ return 1;
39
+ }
40
+ const question = `Q${id[1] ?? ''}`;
41
+ const until = rest
42
+ .join(' ')
43
+ .trim()
44
+ .replace(/^until\s+/i, '');
45
+ if (until.length === 0) {
46
+ process.stderr.write(notice({
47
+ fact: 'defer needs what settles it',
48
+ detail: ['the work the question waits on'],
49
+ remedy: `plumbbob defer ${question} "<what settles it>"`,
50
+ }));
51
+ return 1;
52
+ }
53
+ const path = intentPath(root);
54
+ const date = new Date().toISOString().slice(0, 10);
55
+ const deferral = deferQuestion(readFileSync(path, 'utf8'), question, date, until);
56
+ if (deferral.kind === 'resolved') {
57
+ process.stderr.write(notice({ fact: `${question} is already resolved`, detail: ['its opener says so'] }));
58
+ return 1;
59
+ }
60
+ if (deferral.kind === 'missing') {
61
+ process.stderr.write(notice({
62
+ fact: `no open ${question} in the plan`,
63
+ detail: [deferral.open.length === 0 ? 'no open questions' : `open: ${deferral.open.join(', ')}`],
64
+ remedy: 'fix the handle',
65
+ }));
66
+ return 1;
67
+ }
68
+ writeFileSync(path, deferral.intent);
69
+ printEnding(root, {
70
+ tier: 'driver',
71
+ render: 'defer',
72
+ text: ending({
73
+ // The parser hands back the marker's until-clause as the opener now
74
+ // reads it, `until` and all, so the lead says what the file says.
75
+ lead: transition({ label: 'Deferred', fact: `${plainHandle(deferral.question)} ${deferral.question.deferred ?? `until ${until}`}` }),
76
+ pointer: driverPointer(root, activeBuild(root)),
77
+ }),
78
+ });
79
+ return 0;
80
+ }
81
+ /**
82
+ * One advisory for each open question a park or a tangent names that still
83
+ * counts as a call owed, with the deferral beneath it: setting a question
84
+ * aside does not defer it, and the card would go on counting it as to answer.
85
+ * Empty when the text names none.
86
+ */
87
+ export function setAsideAdvisories(text, rows) {
88
+ return questionsToAnswerNamed(text, rows).map((q) => advisory({
89
+ fact: `${plainHandle(q)} still counts as a question to answer`,
90
+ remedy: `plumbbob defer ${q.id} "<what settles it>", if the work will settle it`,
91
+ }));
92
+ }
93
+ /** A question's handle as a line of prose names it: the id, then its slug. */
94
+ function plainHandle(q) {
95
+ return q.slug === null ? q.id : `${q.id} (${q.slug})`;
96
+ }
@@ -51,7 +51,7 @@ import { hasStep } from "../lib/steplist.js";
51
51
  import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
52
52
  import { rerootLinks } from "../lib/links.js";
53
53
  import { buildFolder, buildFolderIgnored, buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, planCommitted, readPause, readStats, readStop, readTurn, resolveBuild, seamPath, sidecarDir, stepPath, tickPath, writePause, writeStop, } from "../lib/sidecar.js";
54
- import { buildOrderPhrase, countDiff, diffFileItems, diffRowValue, foldVerdict, lastLedgerSha, measureRange, normalizeDetailHeadings, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOpenQuestionRows, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, questionTotals, rangeLanded, rangeSpan, rangeStanding, recapLines, remainingOrder, resolveRun, runBracket, seamRowFromDiff, spentRowValue, stopFollowUp, summaryCheckRow, } from "../lib/orient.js";
54
+ import { buildOrderPhrase, countDiff, diffFileItems, diffRowValue, foldVerdict, lastLedgerSha, markStepDone, measureRange, normalizeDetailHeadings, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOpenQuestionRows, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, questionTotals, rangeLanded, rangeSpan, rangeStanding, recapLines, remainingOrder, resolveRun, runBracket, seamRowFromDiff, spentRowValue, stopFollowUp, summaryCheckRow, } from "../lib/orient.js";
55
55
  // The inline-diff threshold (docs/presentation.md): at 20 changed lines or
56
56
  // fewer the patch rides the turn; at 21 it stays in the working tree behind
57
57
  // the counted diff row.
@@ -1281,9 +1281,11 @@ const ORDER_CAP = 5;
1281
1281
  * becomes the header, which is the Verdict wherever one was measured
1282
1282
  * (D111 (state-table)).
1283
1283
  *
1284
- * The Range, Order, and Parked rows are the segments the note beneath Next Up
1285
- * used to carry on one indented line; as rows each one has a label of its own,
1286
- * the way the human scans for it.
1284
+ * The Order and Parked rows are segments the note beneath Next Up used to
1285
+ * carry on one indented line; as rows each one has a label of its own, the way
1286
+ * the human scans for it. The range is Next Up's second line, a row with a
1287
+ * blank label, so it reads as the next range beside the next step rather than
1288
+ * as the range the build is in (D122 (next-range-under-next-up)).
1287
1289
  */
1288
1290
  function stateRows(input) {
1289
1291
  const steps = parseOrderedSteps(input.intent);
@@ -1296,7 +1298,7 @@ function stateRows(input) {
1296
1298
  input.now === null ? null : { label: 'Now', value: input.now },
1297
1299
  input.stop === null ? null : { label: 'Stop', value: input.stop },
1298
1300
  { label: 'Next Up', value: pointerText(input.nextUp, input.where) },
1299
- range.length === 0 ? null : { label: 'Range', value: range },
1301
+ range.length === 0 ? null : { label: '', value: range },
1300
1302
  order.length === 0 ? null : { label: 'Order', value: order },
1301
1303
  parks.total === 0 ? null : { label: 'Parked', value: parkedValue(parks, input.now !== null) },
1302
1304
  questions.toAnswer + questions.deferred === 0 || input.questions === false ? null : { label: 'Questions', value: questionsValue(questions) },
@@ -1336,32 +1338,51 @@ function pointerLine(nextUp, where) {
1336
1338
  return nextUpLine(nextUp, where);
1337
1339
  }
1338
1340
  /**
1339
- * The Range row's fact: the run `/plumbbob:run` typed now would walk,
1340
- * `**3** on Opus: /plumbbob:run (steps 6-9)`, the range's number bold and the
1341
+ * Next Up's range line: the range the next run walks,
1342
+ * `**Range 3** on Opus: /plumbbob:run (steps 6-9)`, its name bold and the
1341
1343
  * model its `- model:` line names beside it, so the model reads as the
1342
1344
  * range's and not the step's (D115 (card-weights)); the re-cut words for a
1343
1345
  * flagged range; the words for a step no range names; or '' for a plan
1344
- * without ranges. The row's label says `Range`, so the fact opens on the
1345
- * range's number.
1346
+ * without ranges. The row has no label of its own, so the fact opens on the
1347
+ * range's name (D122 (next-range-under-next-up)).
1346
1348
  *
1347
1349
  * The bracket is the grant the hook would mint. At a step pause that is the
1348
1350
  * range holding the step in flight, its bracket opening on that step, since a
1349
- * run typed there lands it first; at the pause on a range's last step, where
1350
- * a run ends on that range's stop, the segment names no run and no model
1351
- * (`**3** lands with step 9`).
1351
+ * run typed there lands it first. At the pause on a range's last step, or a
1352
+ * bare range's, a run lands only that step and ends on its range's stop, so
1353
+ * the line names the range the step after it opens instead
1354
+ * (`**Range 4** on Fable: /plumbbob:run once step 9 lands`).
1352
1355
  */
1353
1356
  function rangeSegment(intent, steps, exclude) {
1354
1357
  const paused = steps.some((s) => s.n === exclude && !s.done) ? exclude : null;
1355
1358
  const run = cardRun(intent, paused);
1359
+ if (run.ok && paused !== null && run.left.length === 1) {
1360
+ return afterLanding(intent, paused);
1361
+ }
1362
+ return runSegment(run, (r) => `(${runBracket(r)})`);
1363
+ }
1364
+ /**
1365
+ * The range line at the pause that finishes a range: the run the boundary will
1366
+ * name once the step in flight lands, read from the plan with that step
1367
+ * ticked, and opening once it does. '' when no step is left after it.
1368
+ */
1369
+ function afterLanding(intent, paused) {
1370
+ const next = cardRun(markStepDone(intent, paused), null);
1371
+ return runSegment(next, () => `once step ${paused} lands`);
1372
+ }
1373
+ /**
1374
+ * A run as the range line names it: the range's name, its model, and the run
1375
+ * with `when` closing it; or, where the run would refuse, the words for a step
1376
+ * no range names or a flagged range's re-cut, and '' for any other refusal.
1377
+ */
1378
+ function runSegment(run, when) {
1356
1379
  if (run.ok) {
1357
- const name = `**${run.range.item}**`;
1358
- const lands = paused !== null && run.left.length === 1;
1359
- return lands ? `${name} lands with step ${paused}` : `${name}${onModel(run.range.model)}: /plumbbob:run (${runBracket(run)})`;
1380
+ return `**Range ${run.range.item}**${onModel(run.range.model)}: /plumbbob:run ${when(run)}`;
1360
1381
  }
1361
1382
  if (run.kind === 'uncovered') {
1362
1383
  return run.words;
1363
1384
  }
1364
- return run.kind === 'flagged' ? `**${run.item}** needs a re-cut (${run.words})` : '';
1385
+ return run.kind === 'flagged' ? `**Range ${run.item}** needs a re-cut (${run.words})` : '';
1365
1386
  }
1366
1387
  /**
1367
1388
  * The run `/plumbbob:run` typed now would walk, as the card names it: the
@@ -25,10 +25,11 @@ import { findRepoRoot } from "../lib/git.js";
25
25
  import { ending, notice, printEnding, transition } from "../lib/notice.js";
26
26
  import { appendHarvest, appendToSection, flipParks, harvestLines, logEntry, stopLogLine } from "../lib/buildlog.js";
27
27
  import { checkStopLatch } from "../lib/latch.js";
28
- import { parseBuildRanges, parseDetailStep, parseLastCheckpoint, parseOrderedSteps, stepShas } from "../lib/orient.js";
28
+ import { parseBuildRanges, parseDetailStep, parseLastCheckpoint, parseOpenQuestionRows, parseOrderedSteps, stepShas } from "../lib/orient.js";
29
29
  import { activeBuild, buildLogPath, checkpointsPath, clearDetail, clearStop, detailPath, hasSession, intentPath, readStop, } from "../lib/sidecar.js";
30
30
  import { hasStep } from "../lib/steplist.js";
31
31
  import { boundaryEnding, harvestProposals, isHarvestDetail, parkList, parseDetailRange, stopRecord, } from "./handoff.js";
32
+ import { setAsideAdvisories } from "./defer.js";
32
33
  // The three classes a park settles as, in the order the ending counts them.
33
34
  const CLASSES = ['blocker', 'tangent', 'pivot'];
34
35
  // Where a refusal about the proposals sends the model: the file it wrote them in.
@@ -100,10 +101,21 @@ export function harvest(cwd, _args) {
100
101
  const lead = stop === null
101
102
  ? transition({ label: 'Recorded', fact: `${settled.length} parked idea${settled.length === 1 ? '' : 's'}`, detail: [counted] })
102
103
  : transition({ label: 'Stop', fact: `**Range ${stop.item}** recorded`, detail: [counted] });
104
+ // A tangent naming a question that still waits on the human has set it
105
+ // aside without deferring it, and the card would go on counting it as a
106
+ // call owed (D123 (defer-records)).
107
+ const tangents = settled
108
+ .filter((p) => p.kind === 'tangent')
109
+ .map((p) => `${p.text} ${p.reason}`)
110
+ .join(' ');
103
111
  printEnding(root, {
104
112
  tier: 'orientation',
105
113
  render: 'harvest',
106
- text: ending({ lead, state: boundaryEnding(root, slug, null).state }),
114
+ text: ending({
115
+ lead,
116
+ advisories: setAsideAdvisories(tangents, parseOpenQuestionRows(intent)),
117
+ state: boundaryEnding(root, slug, null).state,
118
+ }),
107
119
  });
108
120
  return 0;
109
121
  }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.13.7",
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
@@ -202,8 +208,8 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
202
208
  Readout fence (its measured `check`, `seam`, `diff`, and `spent` rows folded with your
203
209
  three, the changed files listed beneath `diff`), a `diff` fence when the change is 20
204
210
  lines or fewer, Your Call, the state table (the Verdict folded worst-of from the same
205
- rows, the Now row naming the step in flight, Next Up, and the Range, Order, and Parked
206
- 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
207
213
  once: a second run at the same pause prints the one-line reminder instead of the card,
208
214
  so if that line comes back where the card belongs (you ran handoff earlier in this
209
215
  turn), run `plumbbob handoff --full` and paste that. The gate verdict's one home is that `check`
@@ -399,8 +405,8 @@ out, or the next step in build order is one the range does not name),
399
405
  `Parked` row, `2 of 7 open`), the move is `/plumbbob:harvest`, then the run the card names.
400
406
  - **After an early stop, settle the step, then rerun what is left.** A halt on trouble
401
407
  inside a range ends at that step's pause, and the grant ends with the turn. Once the
402
- human approves and the step lands, the state table's Range row names the run that lands
403
- what is left of the range (`| Range | **3** on Opus: /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
404
410
  what the human types to carry on; `/plumbbob:build 8-9` lands the same steps by hand.
405
411
 
406
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)
@@ -58,11 +58,25 @@ Then:
58
58
  writing it above the capture, or stacking a second ending under the block are the ways
59
59
  this goes wrong.
60
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
+
61
74
  ## The one hard contract
62
75
 
63
76
  The capture itself is the **dumb CLI**, never an edit. This skill carries **no Edit and
64
- no Write tool** on purpose: you may not append to `build-log.md` yourself. Compose, get
65
- 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
66
80
  never comes, capture nothing. (When the human's **own message** states the idea, a
67
81
  tangent they raise mid-build, that message **is** the approval: capture it directly
68
82
  with `plumbbob park`, don't ask again. Deferring in words without the CLI line captures
@@ -3,7 +3,7 @@ name: plan
3
3
  description: "Frame a fresh goal and author the whole plan (Frame, Decisions, Constraints, all Steps, and the build ranges) before any code. Three input modes: no arg interviews you; a file path (or @-mention) absorbs a spec; any other text expands your inline intent."
4
4
  argument-hint: "[spec-path | intent]"
5
5
  disable-model-invocation: true
6
- allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob handoff:*), Bash(plumbbob start:*), Bash(plumbbob checkpoint:*), Bash(plumbbob agent list:*)
6
+ allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob handoff:*), Bash(plumbbob start:*), Bash(plumbbob checkpoint:*), Bash(plumbbob defer:*), Bash(plumbbob agent list:*)
7
7
  ---
8
8
 
9
9
  # PlumbBob: plan a goal (the whole-goal move)
@@ -85,13 +85,18 @@ The argument only seeds how you get there.
85
85
  in one reply: on each opener line swap `*resolve by:*` for `*resolved:* <date>, <the
86
86
  answer>`, add the Decision the answer settles, and run `plumbbob handoff --plan` again.
87
87
  A question the human would rather let the work answer is deferred, not resolved: on
88
- `defer Q3 until <what settles it>`, keep the question and swap its `*resolve by:*` for
88
+ `defer Q3 until <what settles it>`, run `plumbbob defer Q3 "<what settles it>"`, which
89
+ keeps the question and swaps its `*resolve by:*` for
89
90
  `*deferred:* <date>, until <what settles it>` on the opener line, then run
90
91
  `plumbbob handoff --plan` again. It stays open, the card lists it on a `Deferred` line
91
92
  beneath the table, out of reach of `yes to all leans`, and the Questions row counts it
92
93
  apart (`**1** to answer · 2 deferred`); once the work answers it, swap the marker for
93
94
  `*resolved:*` as above
94
95
  ([D120 (deferred-questions)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d120)).
96
+ A question the human keeps open on purpose, as the running log of something the build
97
+ tunes, is deferred the same way, until the work it logs; left on `*resolve by:*`, the
98
+ card counts it as a call still owed
99
+ ([D123 (defer-records)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d123)).
95
100
  Deferring is the human's call, never yours: a lean you are unsure of is still a lean.
96
101
  4. **Author the Steps.** Write the **full build plan** under `## Steps` (each step a
97
102
  small, verifiable increment) in the exact format the parser reads:
@@ -180,7 +185,7 @@ The argument only seeds how you get there.
180
185
 
181
186
  **The card names the run, not the item.** The item's command is the range's
182
187
  definition: the form the parser reads, and the kind of stop it makes. The card names
183
- `/plumbbob:run` in the Range row beneath Next Up for the range holding the next step,
188
+ `/plumbbob:run` on the range line beneath Next Up for the range holding the next step,
184
189
  with the steps it will land beside it, and `/plumbbob:run 3` runs range 3 by its item number
185
190
  ([D95 (run-verb)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d95)).
186
191
  A range grants nothing until the human types the run that names it, and then the turn
@@ -244,7 +249,7 @@ The argument only seeds how you get there.
244
249
  refusal.)
245
250
  - **The plan commit prints its own close.** `plumbbob checkpoint --plan` emits the
246
251
  whole boundary ending: `**Plan**: committed (<sha>)`, any advisory, and the state
247
- table headed by the pointer at the step the build starts on, its Range row naming the
252
+ table headed by the pointer at the step the build starts on, the range line beneath it naming the
248
253
  run that lands that step's range when the plan declares ranges. Relay that block whole and run no second command; the
249
254
  plan-pause card above is the one place `handoff` is still called, because no verb has
250
255
  run yet there.
@@ -3,7 +3,7 @@ name: refine
3
3
  description: "Keep intent.md true: attack the plan for holes (append as Open questions) and refine or repair the Frame, Decisions, Constraints, Steps, and build ranges to match reality (`ranges` re-cuts the whole section). Usable at any point; you propose, the human approves."
4
4
  argument-hint: "[focus | ranges]"
5
5
  disable-model-invocation: true
6
- allowed-tools: Read, Edit, Bash(plumbbob status:*), Bash(plumbbob order:*)
6
+ allowed-tools: Read, Edit, Bash(plumbbob status:*), Bash(plumbbob order:*), Bash(plumbbob defer:*)
7
7
  ---
8
8
 
9
9
  # PlumbBob: refine the plan
@@ -45,10 +45,15 @@ is fine; refining is always available.
45
45
  message (`yes to all leans`, or `yes to all leans except Q3: <their answer>`), no
46
46
  doc-flipping, no extra round-trip per hole. A resolved question gets
47
47
  `*resolved:* <date>, <the answer>` in place of its `*resolve by:*` on the opener line. The human
48
- can also leave one to the work (`defer Q3 until <what settles it>`): keep the question
49
- and swap its `*resolve by:*` for `*deferred:* <date>, until <what settles it>` on the
50
- opener line, and the table moves it to a `Deferred` line beneath, still open
48
+ can also leave one to the work (`defer Q3 until <what settles it>`): run
49
+ `plumbbob defer Q3 "<what settles it>"`, which keeps the question and swaps its
50
+ `*resolve by:*` for `*deferred:* <date>, until <what settles it>` on the opener line,
51
+ and the table moves it to a `Deferred` line beneath, still open
51
52
  ([D120 (deferred-questions)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d120)).
53
+ A question the table still shows that the human has already set aside (parked, settled
54
+ as a tangent, or kept open as a log) is drift: propose deferring it, until what the
55
+ build says it waits on, rather than asking it again
56
+ ([D123 (defer-records)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d123)).
52
57
  Never retype the table as a list of your own
53
58
  ([D118 (questions-table)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d118)).
54
59
  - **Repair (re-sync to reality).** When the plan has drifted (a Decision was overtaken
@@ -3,7 +3,7 @@ name: run
3
3
  description: "Run what is left of the range holding the next step, or of range N: the plan's `## Build ranges` names the steps, the turn hook mints their grant from the plan, and the run walks them the way a typed range is walked, then ends on the stop card, the range's review with every open park proposed, which one reply settles. A bare range builds its one step to its pause. Reads the build skill beside it for the per-step loop."
4
4
  argument-hint: "[range-number]"
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:*), Bash(plumbbob harvest:*), Bash(plumbbob order:*), Bash(git log:*)
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:*), Bash(plumbbob harvest:*), Bash(plumbbob order:*), Bash(git log:*)
7
7
  ---
8
8
 
9
9
  # PlumbBob: run a range
@@ -223,8 +223,9 @@ The card's proposals are settled in one reply
223
223
  and the Your Call block names the replies. Read the human's message as one of them:
224
224
 
225
225
  - **`looks good`** confirms the review and locks in every lean as proposed, as the line
226
- beneath the card's Your Call label says. Fold each confirmed blocker into `intent.md` by
227
- the harvest skill's rules first, then run `plumbbob harvest`, which flips the parks,
226
+ beneath the card's Your Call label says. Fold each confirmed blocker into `intent.md`, and
227
+ defer each open question a confirmed tangent sets aside, by the harvest skill's rules
228
+ first, then run `plumbbob harvest`, which flips the parks,
228
229
  writes the Harvest lines, and writes the range's review beneath a dated line in the Log.
229
230
  A blocker that becomes a step goes into a range still to come, or a bare range of its
230
231
  own, never into the range this card reviews: that range has landed, its call is the one
@@ -250,7 +251,7 @@ and the Your Call block names the replies. Read the human's message as one of th
250
251
  `plumbbob status --run "<the same argument>"` again. When its grant line says
251
252
  `unlike the run`, the record re-cut the range the run would walk (a blocker added or moved a
252
253
  step in it), and the grant the hook minted before the record no longer covers it: relay
253
- the record's ending whole and end the turn there, where its Range row names the re-cut run
254
+ the record's ending whole and end the turn there, where the range line beneath its Next Up names the re-cut run
254
255
  for the human to type
255
256
  ([D102 (settle-in-one-reply)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d102)).
256
257
  Otherwise relay the record's ending and walk the run from section 4.
@@ -149,8 +149,9 @@ build reports zero open questions
149
149
  fill it in and it counts.
150
150
 
151
151
  When the human would rather let the work answer a question, it is deferred, not
152
- resolved: keep the question and swap *resolve by:* for *deferred:* <date>, until
153
- <what settles it> ON THE OPENER LINE, so the opener reads
152
+ resolved: `plumbbob defer Q4 "<what settles it>"` keeps the question and swaps
153
+ *resolve by:* for *deferred:* <date>, until <what settles it> ON THE OPENER LINE, so
154
+ the opener reads
154
155
 
155
156
  **Q4 (latency-budget)**: What latency budget do we hold?, *deferred:* 2026-10-31, until step 4's latency numbers
156
157
 
@@ -158,6 +159,10 @@ It stays open and still counts, but the card lists it on a line beneath the tabl
158
159
  rather than in it, so `yes to all leans` leaves it alone. Once the work answers it,
159
160
  swap the marker for *resolved:* as above
160
161
  ([D120 (deferred-questions)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d120)).
162
+ A question parked, settled as a tangent, or kept open as the running log of something
163
+ the build tunes waits on the work too, so it is deferred the same way; left on
164
+ *resolve by:*, the card counts it as a call still owed
165
+ ([D123 (defer-records)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d123)).
161
166
 
162
167
  Size to the work: a tiny build's single, obvious question may stay one bare line;
163
168
  the *plain:*/*lean:* pair earns its keep only when a human decision genuinely waits