plumbbob 0.13.6 → 0.13.7

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.6",
4
+ "version": "0.13.7",
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
@@ -175,9 +175,9 @@ In Claude Code, inside any git repo with a clean tree:
175
175
 
176
176
  | **Verdict** | ● Plumb |
177
177
  | --- | --- |
178
- | Now | Step 1 of 3 - feat(limiter): add a token-bucket limiter |
179
- | Next Up | Step 2 of 3 - feat(login): wire the limiter into POST /login (model: **Sonnet**, details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28)) |
180
- | Range | 1 of 2 lands with step 1 |
178
+ | Now | **Step 1** on Opus - feat(limiter): add a token-bucket limiter |
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 |
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
 
@@ -200,8 +200,8 @@ In Claude Code, inside any git repo with a clean tree:
200
200
 
201
201
  | **Verdict** | ● Plumb |
202
202
  | --- | --- |
203
- | Next Up | Step 2 of 3 - feat(login): wire the limiter into POST /login (model: **Sonnet**, details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28)) |
204
- | Range | 2 of 2: /plumbbob:run (steps 2-3) |
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) |
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
@@ -61,7 +61,7 @@ const VERBS = [
61
61
  {
62
62
  name: 'status',
63
63
  description: 'print the orientation dashboard, or NO ACTIVE SESSION',
64
- synopsis: ['status [--build <slug>] [--invoked "<args>"] [--run "<args>"]'],
64
+ synopsis: ['status [--build <slug>] [--invoked "<args>"] [--run "<args>"] [--questions]'],
65
65
  flags: [
66
66
  BUILD_FLAG,
67
67
  {
@@ -74,6 +74,7 @@ const VERBS = [
74
74
  value: '"<args>"',
75
75
  gloss: 'the raw /plumbbob:run invocation; prints the run it names above the dashboard, or the refusal as its pointer',
76
76
  },
77
+ { name: '--questions', gloss: "print the plan's open questions as one table (question, why it matters, lean), the deferred ones on a line beneath, in place of the dashboard" },
77
78
  ],
78
79
  notes: 'Read-only; always exits 0. With no cursor it lists the available builds.',
79
80
  },
@@ -0,0 +1,97 @@
1
+ // Relative links written for one directory, re-aimed for another. A section of
2
+ // `.plumbbob/detail.md` cites `builds/<slug>/intent.md#d5` relative to
3
+ // `.plumbbob/`; copied verbatim into `builds/<slug>/build-log.md` the same
4
+ // target now points one directory too deep and the link breaks. Only the copy
5
+ // step knows both directories, so this is the one place the target moves:
6
+ // every inline link and image in prose gets its target resolved against the
7
+ // directory the text was written for and re-expressed from the directory it is
8
+ // bound for. Code is never touched: a fenced block is tracked across lines and
9
+ // an inline code span across one, because a link inside a sample is part of
10
+ // the sample. Pure string work; no fs, no process, `node:path` only.
11
+ import { relative, resolve, sep } from 'node:path';
12
+ import { closesFence, fenceOpening } from "./orient.js";
13
+ /** One inline code span on a line: a run of backticks up to the next run of the same length. */
14
+ const CODE_SPAN = /(`+).*?\1/g;
15
+ /**
16
+ * One inline link or image in prose, in three parts: everything up to and
17
+ * including the opening parenthesis, the target, and the rest (an optional
18
+ * quoted title, then the closing parenthesis). An angle-bracketed target does
19
+ * not match, so it rides through untouched.
20
+ */
21
+ const LINK = /(!?\[[^\]]*\]\()([^\s()<>]*)((?:\s+(?:"[^"]*"|'[^']*'|\([^()]*\)))?\))/g;
22
+ /** A target that names its own scheme (`https:`, `mailto:`), which no directory move can change. */
23
+ const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
24
+ /**
25
+ * Re-aim every relative link in `text` so it resolves the same from `toDir`
26
+ * as it did from `fromDir`.
27
+ *
28
+ * A link aimed at `builds/x/intent.md#d5`, written for `.plumbbob/`, aims at
29
+ * `intent.md#d5` once it is bound for `.plumbbob/builds/x/`; `../README.md` becomes
30
+ * `../../../README.md`. A fragment rides along unchanged. The text comes
31
+ * back byte for byte when the two directories are one, when no link is
32
+ * relative, or when every link sits inside a fence or a code span.
33
+ */
34
+ export function rerootLinks(text, fromDir, toDir) {
35
+ const from = resolve(fromDir);
36
+ const to = resolve(toDir);
37
+ if (from === to)
38
+ return text;
39
+ let fence = null;
40
+ const lines = [];
41
+ for (const line of text.split('\n')) {
42
+ if (fence !== null) {
43
+ if (closesFence(line, fence))
44
+ fence = null;
45
+ lines.push(line);
46
+ continue;
47
+ }
48
+ const opened = fenceOpening(line);
49
+ if (opened !== null) {
50
+ fence = opened;
51
+ lines.push(line);
52
+ continue;
53
+ }
54
+ lines.push(rerootLine(line, from, to));
55
+ }
56
+ return lines.join('\n');
57
+ }
58
+ /**
59
+ * One unfenced line with its prose segments rewritten and its code spans
60
+ * passed through.
61
+ *
62
+ * The line splits at every code span; the span is copied as it stands and the
63
+ * text between spans is where links move. A lone backtick with no closer is
64
+ * prose, as it is to a markdown renderer.
65
+ */
66
+ function rerootLine(line, from, to) {
67
+ let out = '';
68
+ let last = 0;
69
+ for (const span of line.matchAll(CODE_SPAN)) {
70
+ out += rerootProse(line.slice(last, span.index), from, to) + span[0];
71
+ last = span.index + span[0].length;
72
+ }
73
+ return out + rerootProse(line.slice(last), from, to);
74
+ }
75
+ /** A prose segment with the target of every inline link and image re-aimed. */
76
+ function rerootProse(prose, from, to) {
77
+ return prose.replace(LINK, (_, open, target, close) => `${open}${rerootTarget(target, from, to)}${close}`);
78
+ }
79
+ /**
80
+ * One link target, moved from `from` to `to` when it is relative and returned
81
+ * as written otherwise.
82
+ *
83
+ * Untouched: an empty target, a bare `#anchor`, a root-relative `/docs/a.md`,
84
+ * and anything with a scheme. A relative target resolves against `from`, is
85
+ * re-expressed from `to` with posix separators, and keeps its `#fragment`.
86
+ * A target that lands on `to` itself comes back as `.` rather than as
87
+ * nothing.
88
+ */
89
+ function rerootTarget(target, from, to) {
90
+ if (target === '' || target.startsWith('#') || target.startsWith('/') || SCHEME.test(target))
91
+ return target;
92
+ const hash = target.indexOf('#');
93
+ const file = hash === -1 ? target : target.slice(0, hash);
94
+ const fragment = hash === -1 ? '' : target.slice(hash);
95
+ const moved = relative(to, resolve(from, file)).split(sep).join('/');
96
+ return `${moved === '' ? '.' : moved}${fragment}`;
97
+ }
@@ -91,7 +91,7 @@ export function ending(e) {
91
91
  * ```text
92
92
  * | **Verdict** | ● Plumb |
93
93
  * | --- | --- |
94
- * | Now | Step 2 of 3 - feat(login): wire the limiter into POST /login |
94
+ * | Now | **Step 2** on Sonnet - feat(login): wire the limiter into POST /login |
95
95
  * ```
96
96
  */
97
97
  export function stateTable(rows) {
@@ -70,8 +70,10 @@ export function parseSteps(intent) {
70
70
  const block = lines.slice(s.idx, blockEnd).join('\n');
71
71
  const dw = /\*\*done when:\*\*\s*(.+)/i.exec(block);
72
72
  const doneWhen = dw ? (dw[1] ?? '').trim() : null;
73
+ // A model line still opening on the template's `<...>` fill-in recommends
74
+ // nothing, so it reads as no line at all rather than as a model named `<smallest`.
73
75
  const md = /^\s*-\s*model:\s*(.+)$/im.exec(block);
74
- const model = md ? (md[1] ?? '').trim() : null;
76
+ const model = md && !/^<[^<>]+>/.test((md[1] ?? '').trim()) ? (md[1] ?? '').trim() : null;
75
77
  return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model, line: offset + s.idx + 1 };
76
78
  });
77
79
  }
@@ -1127,14 +1129,115 @@ export function markStepDone(intent, n) {
1127
1129
  * substring read that as resolved and dropped a live hole off the dashboard. And an
1128
1130
  * opener whose body has not been filled in yet, still opening on its `<...>`
1129
1131
  * fill-in, is scaffold rather than a question, which is what makes a fresh build
1130
- * report zero rather than one.
1132
+ * report zero rather than one. A question deferred to the work still counts:
1133
+ * nothing has been decided (D120 (deferred-questions)).
1131
1134
  */
1132
1135
  export function parseOpenQuestions(intent) {
1133
- const opener = /^-\s+(?:<a id="[^"]*"><\/a>\s*)?\*{0,2}Q\d+(?: \([^)]+\))?\*{0,2}:\s*(.*)$/;
1134
- return sectionLines(intent, '## Open questions').lines.filter((l) => {
1135
- const body = opener.exec(l.trim())?.[1];
1136
- return body !== undefined && !/\bresolved\b/i.test(l) && !/^<[^>]*>/.test(body);
1137
- }).length;
1136
+ return parseOpenQuestionRows(intent).length;
1137
+ }
1138
+ // An open question's opener: the handle, an optional anchor and slug, then
1139
+ // the body after the colon (D76 (resolved-on-opener)).
1140
+ const QUESTION_OPENER = /^-\s+(?:<a id="[^"]*"><\/a>\s*)?\*{0,2}(Q\d+)(?: \(([^)]+)\))?\*{0,2}:\s*(.*)$/;
1141
+ // A sub-line beneath an opener: `- *plain:* …` or `- *lean:* …`.
1142
+ const QUESTION_FIELD = /^-\s+\*(plain|lean):\*\s*(.*)$/;
1143
+ /**
1144
+ * The open questions, each with what the table shows, in the order the plan
1145
+ * lists them. The rules `parseOpenQuestions` counts by decide which openers
1146
+ * are open: a `resolved` marker on the opener line closes it, and a body still
1147
+ * opening on its `<...>` fill-in is scaffold. The `*plain:*` sub-line is why
1148
+ * the question matters, the `*lean:*` sub-line the proposed answer, each with
1149
+ * any lines it wraps onto (D118 (questions-table)).
1150
+ *
1151
+ * A `*deferred:*` marker on the opener keeps the question open and records what
1152
+ * will settle it. Its until-clause is the human's own words, so a "resolved"
1153
+ * inside it does not close the question; only the literal `*resolved:*` marker
1154
+ * does, wherever it sits on the line (D120 (deferred-questions)).
1155
+ */
1156
+ export function parseOpenQuestionRows(intent) {
1157
+ const { offset, lines } = sectionLines(intent, '## Open questions');
1158
+ const rows = [];
1159
+ let current = null;
1160
+ let field = null;
1161
+ lines.forEach((raw, i) => {
1162
+ const text = raw.trim();
1163
+ const opener = QUESTION_OPENER.exec(text);
1164
+ if (opener !== null) {
1165
+ const body = opener[3] ?? '';
1166
+ const open = !resolvedOpener(raw) && !/^<[^>]*>/.test(body);
1167
+ current = open
1168
+ ? { id: opener[1] ?? '', slug: opener[2] ?? null, line: offset + i + 1, question: questionText(body), why: null, lean: null, deferred: deferredText(body) }
1169
+ : null;
1170
+ if (current !== null)
1171
+ rows.push(current);
1172
+ field = null;
1173
+ return;
1174
+ }
1175
+ const sub = QUESTION_FIELD.exec(text);
1176
+ if (current !== null && sub !== null) {
1177
+ field = sub[1] === 'plain' ? 'why' : 'lean';
1178
+ current[field] = (sub[2] ?? '').trim();
1179
+ return;
1180
+ }
1181
+ if (current !== null && field !== null && text.length > 0 && /^\s/.test(raw) && !text.startsWith('-')) {
1182
+ current[field] = `${current[field] ?? ''} ${text}`.trim();
1183
+ return;
1184
+ }
1185
+ field = null;
1186
+ });
1187
+ return rows;
1188
+ }
1189
+ // The marker a question deferred to the work carries on its opener line.
1190
+ const DEFERRED_MARKER = /\*deferred:\*/i;
1191
+ /**
1192
+ * Whether an opener line closes its question: "resolved" as a whole word
1193
+ * anywhere before a `*deferred:*` marker (D76 (resolved-on-opener)), or the
1194
+ * literal `*resolved:*` marker anywhere on the line. The until-clause after a
1195
+ * deferral is free text, so a bare "resolved" there leaves the question open.
1196
+ */
1197
+ function resolvedOpener(raw) {
1198
+ const deferredAt = raw.search(DEFERRED_MARKER);
1199
+ const head = deferredAt === -1 ? raw : raw.slice(0, deferredAt);
1200
+ return /\bresolved\b/i.test(head) || /\*resolved:\*/i.test(raw);
1201
+ }
1202
+ /** An opener's question: its body without the trailing `*resolve by:*` or `*deferred:*` tag. */
1203
+ function questionText(body) {
1204
+ return body.replace(/,?\s*\*(?:resolve by|deferred):\*.*$/i, '').trim();
1205
+ }
1206
+ /**
1207
+ * What a deferred opener says will settle it: the text after its `*deferred:*`
1208
+ * marker, without the leading date or a `*resolve by:*` tag left beside it,
1209
+ * and empty for a bare marker. Null when the opener carries no marker.
1210
+ */
1211
+ function deferredText(body) {
1212
+ const at = body.search(DEFERRED_MARKER);
1213
+ if (at === -1) {
1214
+ return null;
1215
+ }
1216
+ return body
1217
+ .slice(at)
1218
+ .replace(DEFERRED_MARKER, '')
1219
+ .replace(/,?\s*\*resolve by:\*.*$/i, '')
1220
+ .trim()
1221
+ .replace(/^\d{4}-\d{2}-\d{2},?\s*/, '')
1222
+ .trim();
1223
+ }
1224
+ /** Split open question rows by who acts on them (D120 (deferred-questions)). */
1225
+ export function questionTotals(rows) {
1226
+ const deferred = rows.filter((q) => q.deferred !== null).length;
1227
+ return { toAnswer: rows.length - deferred, deferred };
1228
+ }
1229
+ /**
1230
+ * The dashboard's question count, split the way the card's Questions row
1231
+ * splits it: `open questions 1 to answer, 2 deferred`, either half alone when
1232
+ * the other is zero, and `open questions 0` with none open. A comma joins the
1233
+ * halves because ` · ` already separates the line's fields.
1234
+ */
1235
+ export function questionsPhrase(totals) {
1236
+ const halves = [
1237
+ ...(totals.toAnswer > 0 ? [`${totals.toAnswer} to answer`] : []),
1238
+ ...(totals.deferred > 0 ? [`${totals.deferred} deferred`] : []),
1239
+ ];
1240
+ return `open questions ${halves.length === 0 ? '0' : halves.join(', ')}`;
1138
1241
  }
1139
1242
  /**
1140
1243
  * Count the park list: the `- [ ]` lines still open, and every checklist line
@@ -1338,7 +1441,7 @@ export function orient(input) {
1338
1441
  lastCheckpoint: parseLastCheckpoint(input.checkpoints),
1339
1442
  parked: parks.open,
1340
1443
  parkedTotal: parks.total,
1341
- openQuestions: parseOpenQuestions(input.intent),
1444
+ questions: questionTotals(parseOpenQuestionRows(input.intent)),
1342
1445
  next: (idle ? ranges.waitingMove : null) ??
1343
1446
  (atBoundary ? ranges.move : null) ??
1344
1447
  nextMove(input.spiking, ordered, input.inFlight, parks.open, requested),
@@ -2079,12 +2182,12 @@ function proseBlocks(text) {
2079
2182
  return blocks;
2080
2183
  }
2081
2184
  /** The fence a line opens: three or more backticks or tildes, indented three spaces at most. Null when it opens none. */
2082
- function fenceOpening(line) {
2185
+ export function fenceOpening(line) {
2083
2186
  const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
2084
2187
  return marker === undefined ? null : { char: marker.charAt(0), length: marker.length };
2085
2188
  }
2086
2189
  /** Whether a line closes an open fence: the same character, at least as many of it, and nothing after. */
2087
- function closesFence(line, fence) {
2190
+ export function closesFence(line, fence) {
2088
2191
  const marker = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
2089
2192
  return marker !== undefined && marker.startsWith(fence.char) && marker.length >= fence.length;
2090
2193
  }
@@ -2254,7 +2357,7 @@ export function formatOrientation(o) {
2254
2357
  cpLine,
2255
2358
  ...receipts,
2256
2359
  ...orderRow,
2257
- `${parkedPhrase({ open: o.parked, total: o.parkedTotal })} · open questions ${o.openQuestions}`,
2360
+ `${parkedPhrase({ open: o.parked, total: o.parkedTotal })} · ${questionsPhrase(o.questions)}`,
2258
2361
  '',
2259
2362
  `next → ${o.next}`,
2260
2363
  ].join('\n');
@@ -20,7 +20,7 @@
20
20
  // a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
21
21
  import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
22
22
  import { dirname, join } from 'node:path';
23
- import { gitPath } from "./git.js";
23
+ import { gitPath, isIgnored } from "./git.js";
24
24
  const DIRNAME = '.plumbbob'; // the sidecar folder name at the repo root
25
25
  /**
26
26
  * The sidecar root: `<root>/.plumbbob`.
@@ -175,6 +175,37 @@ function artifactDir(root, slug) {
175
175
  export function buildFolder(root, slug) {
176
176
  return artifactDir(root, slug);
177
177
  }
178
+ /**
179
+ * Whether git ignores the build folder here, by the repo's own `.gitignore` or
180
+ * by a `.plumbbob/` line in info/exclude (`--local`): the probe `checkpoint
181
+ * --plan` acts on, since an ignored folder lands its plan commit record-only.
182
+ *
183
+ * Best-effort: a probe that throws (a fatal `git check-ignore`) reads as not
184
+ * ignored, so a caller only warning about it stays quiet rather than crashing.
185
+ */
186
+ export function buildFolderIgnored(root, slug) {
187
+ try {
188
+ return isIgnored(root, buildFolder(root, slug));
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ }
194
+ /**
195
+ * Whether the build's plan has landed: a `plan <sha>` line in its checkpoints
196
+ * ledger, which only `checkpoint --plan` writes. A missing ledger reads as not
197
+ * landed.
198
+ */
199
+ export function planCommitted(root, slug) {
200
+ try {
201
+ return readFileSync(checkpointsPath(root, slug), 'utf8')
202
+ .split('\n')
203
+ .some((line) => line.startsWith('plan '));
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
178
209
  /**
179
210
  * The SPIKE marker: a single-purpose presence flag, like SEAM/STEP.
180
211
  *
@@ -217,7 +248,8 @@ export function clearStep(root, slug) {
217
248
  * PAUSE: which card the human has already read, so a later `handoff` prints
218
249
  * the one line that points back at it instead of the card. It holds a step
219
250
  * number once that step's pause card has shown (`2`), or `landed 2` once the
220
- * boundary step 2 landed on has shown.
251
+ * boundary step 2 landed on has shown, `landed 2 answered` when that landing
252
+ * answered step 2's pause card.
221
253
  *
222
254
  * It records that a card showed, never what the card said: a later turn that
223
255
  * changes the work is the model's reply, and the card comes back only on
@@ -233,8 +265,12 @@ export function pausePath(root, slug) {
233
265
  */
234
266
  export function readPause(root, slug) {
235
267
  try {
236
- const m = /^(landed )?(\d+)(?:\s|$)/.exec(readFileSync(pausePath(root, slug), 'utf8').trim());
237
- return m === null ? null : { kind: m[1] === undefined ? 'pause' : 'landed', step: Number(m[2]) };
268
+ const m = /^(landed )?(\d+)(?:\s+(\S+))?/.exec(readFileSync(pausePath(root, slug), 'utf8').trim());
269
+ if (m === null) {
270
+ return null;
271
+ }
272
+ const stamp = { kind: m[1] === undefined ? 'pause' : 'landed', step: Number(m[2]) };
273
+ return stamp.kind === 'landed' && m[3] === 'answered' ? { ...stamp, answered: true } : stamp;
238
274
  }
239
275
  catch {
240
276
  return null;
@@ -246,7 +282,8 @@ export function readPause(root, slug) {
246
282
  */
247
283
  export function writePause(root, slug, stamp) {
248
284
  try {
249
- writeFileSync(pausePath(root, slug), `${stamp.kind === 'landed' ? 'landed ' : ''}${stamp.step}\n`);
285
+ const answered = stamp.kind === 'landed' && stamp.answered === true ? ' answered' : '';
286
+ writeFileSync(pausePath(root, slug), `${stamp.kind === 'landed' ? 'landed ' : ''}${stamp.step}${answered}\n`);
250
287
  }
251
288
  catch {
252
289
  // A read-only tree or a vanished build folder: the card still renders.
@@ -200,13 +200,15 @@ function reservedCallNotice(intent, steps, step) {
200
200
  * That step lands like every step before it, and then the turn ends on its
201
201
  * checkpoint: a clean halt. Saying so as the step is entered puts the fact in
202
202
  * front of the model before it chooses between landing the step and holding it
203
- * at a pause, which is the choice a clean halt kept getting wrong. A range
204
- * reaches its top, or the last undone step under its top when the plan ends
205
- * first, or when the build order puts a number past the top ahead of the rest
206
- * (a range names plan numbers, and the skill halts before one past its top);
207
- * `--auto` reaches the plan's last undone step. A range that lists its steps
208
- * walks them in build order, so its top is the last undone step it names, and
209
- * it reaches no further than a step it does not name.
203
+ * at a pause, which is the choice a clean halt kept getting wrong. The next
204
+ * step is the first undone step in build order other than this one, wherever
205
+ * it sits, because a range names plan numbers and the skill halts before any
206
+ * step the range does not name: a range typed above an earlier undone step
207
+ * halts after its first step, and the detail names the step it halts before.
208
+ * A range reaches its top, or the last undone step it names when the plan ends
209
+ * first or the next step in build order is one it does not name; `--auto`
210
+ * reaches the plan's last undone step. A range that lists its steps walks them
211
+ * in build order, so its top is the last undone step it names.
210
212
  */
211
213
  function lastGrantedNotice(grant, steps, step) {
212
214
  if (grant === null) {
@@ -216,24 +218,30 @@ function lastGrantedNotice(grant, steps, step) {
216
218
  if (grant.kind === 'steps') {
217
219
  if (!hasStep(grant.steps, step))
218
220
  return '';
219
- const rest = steps.slice(steps.findIndex((s) => s.n === step) + 1).filter((s) => !s.done && s.n !== step);
221
+ const rest = steps.filter((s) => !s.done && s.n !== step);
220
222
  const after = rest[0];
221
223
  if (after !== undefined && hasStep(grant.steps, after.n))
222
224
  return '';
225
+ const detail = after === undefined ? [] : [unnamedNext(after.n)];
223
226
  return rest.some((s) => hasStep(grant.steps, s.n))
224
- ? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
225
- : notice({ fact: `step ${step} is the top of the range you granted`, remedy });
227
+ ? notice({ fact: `step ${step} is the last undone step in the range you granted`, detail, remedy })
228
+ : notice({ fact: `step ${step} is the top of the range you granted`, detail, remedy });
226
229
  }
227
230
  if (grant.kind === 'range') {
228
231
  if (step === grant.ceiling) {
229
232
  return notice({ fact: `step ${step} is the top of the range you granted`, remedy });
230
233
  }
231
- const after = steps.slice(steps.findIndex((s) => s.n === step) + 1).find((s) => !s.done);
234
+ const after = steps.find((s) => !s.done && s.n !== step);
232
235
  const more = after !== undefined && after.n <= grant.ceiling;
236
+ const detail = after === undefined ? [] : [unnamedNext(after.n)];
233
237
  return step < grant.ceiling && !more
234
- ? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
238
+ ? notice({ fact: `step ${step} is the last undone step in the range you granted`, detail, remedy })
235
239
  : '';
236
240
  }
237
241
  const more = steps.some((s) => !s.done && s.n !== step);
238
242
  return more ? '' : notice({ fact: `step ${step} is the last undone step in the plan`, remedy });
239
243
  }
244
+ /** The detail naming the step a range halts before: the next undone one in build order, which the range does not name. */
245
+ function unnamedNext(n) {
246
+ return `step ${n} is next in the build order, and the range does not name it`;
247
+ }
@@ -12,7 +12,7 @@
12
12
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { relative } from 'node:path';
14
14
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
15
- import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readRun, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, writePause, } from "../lib/sidecar.js";
15
+ import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readPause, readRun, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, writePause, } from "../lib/sidecar.js";
16
16
  import { runCheck } from "../lib/check.js";
17
17
  import { readCommitBody } from "../lib/commitbody.js";
18
18
  import { checkLatch } from "../lib/latch.js";
@@ -123,8 +123,12 @@ export async function checkpoint(cwd, args) {
123
123
  clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
124
124
  // The pause it stamped is over, and the boundary this ending prints is the
125
125
  // card the stamp now names, so a later handoff here ends on its pointer
126
- // (D110 (reply-after-the-card)); the next step's pause renders whole.
127
- writePause(root, undefined, { kind: 'landed', step });
126
+ // (D110 (reply-after-the-card)); the next step's pause renders whole. A
127
+ // landing that answers this step's own pause card, already shown, says so,
128
+ // and the stop card it may owe prints short (D119 (short-stop-after-a-halt)).
129
+ const shown = readPause(root);
130
+ const answered = shown?.kind === 'pause' && shown.step === step;
131
+ writePause(root, undefined, { kind: 'landed', step, ...(answered ? { answered: true } : {}) });
128
132
  // The whole ending, one block: the lead line, the advisories this checkpoint
129
133
  // alone can state, and the state table headed by the Verdict folded over
130
134
  // what just landed. Read after the flip and the ledger append, so the fold
@@ -181,7 +185,7 @@ function checkpointPlan(root, args) {
181
185
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
182
186
  // The cold read that approved the plan is the Log's first entry, beneath a
183
187
  // dated plan line; the detail file is then spent, the same as at a step.
184
- const pointer = logPlan(root, sha, planRecord(readDetail(root, null) ?? ''));
188
+ const pointer = logPlan(root, sha, planRecord(root, activeBuild(root), readDetail(root, null) ?? ''));
185
189
  clearDetail(root);
186
190
  // Landing the plan consumes `start`'s entry stamp: a later hand-built diff
187
191
  // (no `build <n>`) must find no stale TICK and stay guidance-governed.