plumbbob 0.13.4 → 0.13.6

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.4",
4
+ "version": "0.13.6",
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
@@ -150,12 +150,7 @@ In Claude Code, inside any git repo with a clean tree:
150
150
  diff, the model supplies only the judgment, and nothing is written around it.
151
151
 
152
152
  ````markdown
153
- **Now**: Step 1 of 3 - feat(limiter): add a token-bucket limiter
154
-
155
- **Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute. (details: [detail.md](.plumbbob/detail.md))
156
-
157
- 1. The bucket lives in memory, keyed by IP, refilling on read: five tokens a minute.
158
- 2. The clock is injected, so the test advances time instead of sleeping on it.
153
+ **Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute.
159
154
 
160
155
  **Readout**:
161
156
 
@@ -166,22 +161,27 @@ In Claude Code, inside any git repo with a clean tree:
166
161
  constraints 1 of 1 honored
167
162
  seam held: 2 of 2 declared, no strays
168
163
  diff +61 -3 across 2 files
164
+ - src/limiter.ts +38 -3
165
+ - test/limiter.test.ts +23 -0
169
166
  spent 22 min · 2 turns · 41s gate · green first run
170
167
  ```
171
168
 
172
- **Verdict**: ● Plumb
173
-
174
- **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))
175
- · range 1 of 2 lands with step 1
176
-
177
169
  **Your Call**:
178
170
 
179
171
  - `looks good` → I checkpoint Step 1; back to the boundary
180
172
  - `expand`, or any question → I show more of what is there; nothing changes, and `show the pause` brings all of this back
181
- - anything that reads as direction → I take it as what to change; nothing lands until you approve
173
+ - anything that reads as direction → I make the change and say what moved; nothing lands until you approve
182
174
  - `revert` → I wind the work back to the last checkpoint
183
175
 
176
+ | **Verdict** | ● Plumb |
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 |
181
+
184
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
+
184
+ [more details](.plumbbob/detail.md)
185
185
  ````
186
186
 
187
187
  ![The pause, drawn: a mechanical hand has set its stone on the line and hovers open and still, while a plumb bob in front comes to rest](site/media/pause.svg)
@@ -192,30 +192,30 @@ 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 line beneath Next Up names the run to type
195
+ and returns to the boundary, where the Range row beneath Next Up names the run to type
196
196
  next and the steps it will land:
197
197
 
198
198
  ```text
199
199
  **Checkpoint**: Step 1 complete (a1b2c3d4e, details: [build-log.md:70](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/build-log.md#L70))
200
200
 
201
- **Verdict**: ● Plumb
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)
201
+ | **Verdict** | ● Plumb |
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) |
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)
208
208
 
209
209
  Ask instead (`expand 2`, or any question) and it shows more of what is there and
210
- changes nothing. The answer closes on one line pointing back at the step, not on the
211
- whole block again, since you have already read it; say `show the pause` to see the
212
- block again. Direct it and it takes that as the fix, and nothing lands until you say
213
- `looks good`.
210
+ changes nothing. Direct it and it makes the change and tells you what moved. Either
211
+ way the reply is its own, and it closes on one line pointing back at the step, not on
212
+ the whole block again, since you have already read it; say `show the pause` to see
213
+ the block again. Nothing lands until you say `looks good`.
214
214
  4. **Run again.** Fire `/plumbbob:run` again; re-firing it *is* the clock tick. Range 2
215
215
  lands steps 2 and 3, each on a green check with no pause between them, and ends on the
216
216
  **stop card**: the range's review (a summary of both steps, a readout measured across
217
- them, the Verdict), with every idea parked along the way listed under `Harvested` and
218
- a lean proposed for each. One reply settles it: `looks good` records the review and
217
+ them, the Verdict), with every idea parked along the way in a table under `Harvested`:
218
+ each park, why it matters, and the lean proposed for it. One reply settles it: `looks good` records the review and
219
219
  every lean, and `make P1 a blocker` changes one first. `/plumbbob:build` stays the
220
220
  granular move whenever you want it, one step to its pause. Whenever you lose the
221
221
  thread, `/plumbbob:status` shows where you are and names the next move.
package/dist/cli-core.js CHANGED
@@ -87,17 +87,18 @@ const VERBS = [
87
87
  },
88
88
  {
89
89
  name: 'handoff',
90
- description: 'print the footer card that ends a turn (pause, boundary, plan, driver, or stop), or a reminder for a pause or a stop already shown',
91
- synopsis: ['handoff [<n>] [--full] [--plan] [--driver] [--stop] [--build <slug>]'],
90
+ description: 'print the footer card that ends a turn (pause, boundary, plan, driver, stop, or harvest), or the one line for a card already shown',
91
+ synopsis: ['handoff [<n>] [--full] [--plan] [--driver] [--stop] [--harvest] [--build <slug>]'],
92
92
  args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
93
93
  flags: [
94
- { name: '--full', gloss: 'print the whole pause card, or with --stop the whole stop card, even when it was already shown' },
94
+ { name: '--full', gloss: 'print the whole pause card or boundary, or with --stop the whole stop card, even when it was already shown' },
95
95
  { name: '--plan', gloss: 'render the plan-pause card instead of a step tier' },
96
96
  { name: '--driver', gloss: "render a driver turn's pointer back at the open step" },
97
97
  { name: '--stop', gloss: "render the stop card for the range the last landing completed, from the range's detail file" },
98
+ { name: '--harvest', gloss: "render a boundary harvest's lean table and its moves, from a `# Detail · Harvest` detail file" },
98
99
  BUILD_FLAG,
99
100
  ],
100
- notes: "Writes only the detail file (a blank line around each heading), the PAUSE stamp, and with --stop the STOP stamp; refuses (exit 1) with no session, with two of --plan, --driver, and --stop together, and with --stop when a step is in flight, no range that lands its steps just landed, or the detail file is not that range's.",
101
+ notes: "Writes only the detail file (a blank line around each heading), the PAUSE stamp naming the card shown, and with --stop the STOP stamp; refuses (exit 1) with no session, with two of --plan, --driver, --stop, and --harvest together, with --stop when a step is in flight, no range that lands its steps just landed, or the detail file is not that range's, and with --harvest when the detail file is not a harvest's or no park is open.",
101
102
  },
102
103
  {
103
104
  name: 'check',
@@ -167,7 +168,7 @@ const VERBS = [
167
168
  name: 'harvest',
168
169
  description: "record a harvest from the detail file, and at a run's stop the range's review",
169
170
  synopsis: ['harvest'],
170
- notes: "Reads the detail file's `## Harvest` proposals (`- park 6 → tangent: <reason>`), flips each settled park in build-log.md, appends the dated Harvest lines, and, when the detail is a range's, writes the range's review beneath a dated Log line; then clears the detail file and the STOP stamp, and commits nothing. Refuses (exit 1) with no session, a detail file that is no range's or harvest's, a range's detail in the turn its stop card printed, one no stop card showed, one whose range is open again, or one whose range a step joined after it landed, and a proposal naming a park the list lacks or a class other than blocker, tangent, or pivot.",
171
+ notes: "Reads the detail file's `## Harvest` proposals (`- P6 → tangent: <reason>`, with `- title:` and `- why:` lines beneath), flips each settled park in build-log.md, appends the dated Harvest lines, and, when the detail is a range's, writes the range's review beneath a dated Log line; then clears the detail file and the STOP stamp, and commits nothing. Refuses (exit 1) with no session, a detail file that is no range's or harvest's, a range's detail in the turn its stop card printed, a range no stop card showed, one whose range is open again, or one whose range a step joined after it landed, and a proposal naming a park the list lacks or a class other than blocker, tangent, or pivot.",
171
172
  },
172
173
  {
173
174
  name: 'spike',
@@ -11,9 +11,11 @@
11
11
  // the edge, and an optional indented arrow line carrying what to do next. One
12
12
  // shape, so moving it is one edit rather than a sweep. The pointer at the next
13
13
  // step is not a line's job: `handoff` renders one, and the verb prints it as
14
- // the ending's last part. The formatters are pure string assembly, no fs, no
15
- // gate; `printEnding` is the one function here that writes, and every ending
16
- // a verb prints goes through it so the relay check knows what was said.
14
+ // the ending's last part, either as the state table that closes a card or an
15
+ // orientation ending (D111 (state-table)) or as the one labeled line a driver
16
+ // turn ends on. The formatters are pure string assembly, no fs, no gate;
17
+ // `printEnding` is the one function here that writes, and every ending a verb
18
+ // prints goes through it so the relay check knows what was said.
17
19
  import { writeRelay } from "./sidecar.js";
18
20
  /** The column budget a notice line aims to fit, matching the readout fence. */
19
21
  const WIDTH = 80;
@@ -66,15 +68,48 @@ export function advisory(a) {
66
68
  return line(`${capitalize(trimFact(a.fact))} ⚠`, a);
67
69
  }
68
70
  /**
69
- * Assemble a transition's whole ending: the labeled lead line, the Verdict
70
- * where one was measured, the advisories, the Stop part where a range landed,
71
- * and the pointer.
71
+ * Assemble a transition's whole ending: the labeled lead line, the
72
+ * advisories, then the state table or the driver's pointer.
72
73
  *
73
74
  * The order is the type's field order, so a verb composes parts and never a
74
75
  * sequence. A part with nothing in it vanishes rather than leaving a gap.
75
76
  */
76
77
  export function ending(e) {
77
- return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.stop ?? null, e.pointer ?? null]);
78
+ return blocks([e.lead, ...(e.advisories ?? []), stateTable(e.state ?? []), e.pointer ?? null]);
79
+ }
80
+ /**
81
+ * Render the state table: two columns, one row per fact, the first row the
82
+ * header. '' with no rows, so a caller can pass a table it may not have.
83
+ *
84
+ * The header is the fact the human reads first, the Verdict wherever one was
85
+ * measured, and its label rides bold so it reads as the headline even where a
86
+ * renderer does not bolden a header row. The rows beneath keep plain labels.
87
+ * A table rather than labeled lines because these are the facts the human
88
+ * scans for, and a table is the shape that sets them apart from the prose
89
+ * around them (D111 (state-table)).
90
+ *
91
+ * ```text
92
+ * | **Verdict** | ● Plumb |
93
+ * | --- | --- |
94
+ * | Now | Step 2 of 3 - feat(login): wire the limiter into POST /login |
95
+ * ```
96
+ */
97
+ export function stateTable(rows) {
98
+ const [head, ...body] = rows;
99
+ if (head === undefined)
100
+ return '';
101
+ return [
102
+ `| **${cell(head.label)}** | ${cell(head.value)} |`,
103
+ '| --- | --- |',
104
+ ...body.map((r) => `| ${cell(r.label)} | ${cell(r.value)} |`),
105
+ ].join('\n');
106
+ }
107
+ /**
108
+ * A table cell's text: on one line, with every pipe escaped, so a step title
109
+ * or a park holding one cannot split its row.
110
+ */
111
+ export function cell(text) {
112
+ return text.replace(/\s*\n\s*/g, ' ').replace(/\|/g, '\\|').trim();
78
113
  }
79
114
  /**
80
115
  * Stack a turn's parts: one blank line between each, one beneath the last.
@@ -167,14 +167,14 @@ export function remainingOrder(steps, order, exclude) {
167
167
  return byOrder.some((n, i) => n !== byDocument[i]) ? byOrder : [];
168
168
  }
169
169
  /**
170
- * The build order as the note's labeled field: `order:`, then the next `cap`
171
- * step numbers in sequence, the rest folded to a count (`and 2 more`), the way
172
- * a long detail degrades on every one-liner; '' for an empty order. With no
173
- * cap, the whole sequence.
170
+ * The build order as the state table's Order row says it: the next `cap` step
171
+ * numbers in sequence, the rest folded to a count (`and 2 more`), the way a
172
+ * long detail degrades on every one-liner; '' for an empty order. With no cap,
173
+ * the whole sequence.
174
174
  *
175
- * A label, where the phrase once read `build order 7, 8, 5`, because the
176
- * note's other segments read as labeled fields and a label is how the eye
177
- * finds one in a line of three (D108 (order-label)).
175
+ * The label is the row's, `Order`, which is where the note's `order:` field
176
+ * went when the note became rows (D111 (state-table), revising
177
+ * D108 (order-label)).
178
178
  */
179
179
  export function buildOrderPhrase(order, cap) {
180
180
  if (order.length === 0) {
@@ -182,7 +182,7 @@ export function buildOrderPhrase(order, cap) {
182
182
  }
183
183
  const shown = cap === undefined ? order : order.slice(0, cap);
184
184
  const rest = order.length - shown.length;
185
- return `order: ${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
185
+ return `${shown.join(', ')}${rest > 0 ? ` (and ${rest} more)` : ''}`;
186
186
  }
187
187
  /**
188
188
  * Write `order` as the one numeric line under `## Build order`: in place of
@@ -1602,6 +1602,33 @@ export function countDiff(entries) {
1602
1602
  files: entries.length,
1603
1603
  };
1604
1604
  }
1605
+ // How many lines the changed files take beneath the diff row before the rest
1606
+ // folds to a count: seven named and `and 5 more`, so the list never runs past
1607
+ // eight lines.
1608
+ const DIFF_FILES_CAP = 8;
1609
+ /**
1610
+ * The changed files beneath the diff row, one `<path> +<added> -<removed>`
1611
+ * item per file, in git's path order. A file several commits touched is
1612
+ * counted once, its lines summed. Past eight files the tail folds to a count
1613
+ * (`and 5 more`), never a silent cut, and a path is never shortened: it is the
1614
+ * name the human opens, and a cut one is wrong where a wrapped one is legible
1615
+ * (D113 (short-card)).
1616
+ */
1617
+ export function diffFileItems(entries) {
1618
+ const byPath = new Map();
1619
+ for (const e of entries) {
1620
+ const prior = byPath.get(e.path) ?? { added: 0, removed: 0 };
1621
+ byPath.set(e.path, { added: prior.added + e.added, removed: prior.removed + e.removed });
1622
+ }
1623
+ const items = [...byPath.entries()]
1624
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
1625
+ .map(([path, c]) => `${path} +${c.added} -${c.removed}`);
1626
+ if (items.length <= DIFF_FILES_CAP) {
1627
+ return items;
1628
+ }
1629
+ const shown = items.slice(0, DIFF_FILES_CAP - 1);
1630
+ return [...shown, `and ${items.length - shown.length} more`];
1631
+ }
1605
1632
  /**
1606
1633
  * The diff row's value: `+<added> -<removed> across <N> files`, plus the
1607
1634
  * `inline below` pointer when the patch rides the turn. Null when nothing
@@ -1737,6 +1764,9 @@ export function recapLines(rows, extras) {
1737
1764
  }
1738
1765
  if (extras.diff !== null) {
1739
1766
  body.push(`${'diff'.padEnd(13)}${extras.diff}`);
1767
+ for (const item of extras.files ?? []) {
1768
+ body.push(`${RECAP_INDENT}- ${item}`);
1769
+ }
1740
1770
  }
1741
1771
  if (extras.spent !== null) {
1742
1772
  body.push(`${'spent'.padEnd(13)}${extras.spent}`);
@@ -1780,6 +1810,7 @@ export function measureRange(input) {
1780
1810
  steps: stepsRow(held, shas),
1781
1811
  rows,
1782
1812
  diff: rangeDiffValue(landed.flatMap((l) => l.files)),
1813
+ files: diffFileItems(landed.flatMap((l) => l.files).filter((f) => !isArtifactPath(f.path))),
1783
1814
  spent: rangeSpentValue(stats),
1784
1815
  stats: {
1785
1816
  driftWarnings: sum('driftWarnings'),
@@ -1998,13 +2029,64 @@ function sectionProse(detail, heading) {
1998
2029
  if (text.length === 0) {
1999
2030
  return null;
2000
2031
  }
2001
- return text
2002
- .split(/\n{2,}/)
2003
- .map((paragraph) => paragraph
2004
- .split('\n')
2005
- .map((l) => l.trim())
2006
- .join(' '))
2007
- .join('\n\n');
2032
+ return proseBlocks(text).join('\n\n');
2033
+ }
2034
+ /**
2035
+ * Prose's blocks, in order: each paragraph unwrapped onto one line, and each
2036
+ * fenced block kept line for line with its fences trimmed flush.
2037
+ *
2038
+ * A command the human is meant to copy rides a fence of its own
2039
+ * (D114 (command-fences)), and joining its lines would run two commands into
2040
+ * one. A fence opened straight under a sentence still ends that paragraph, so
2041
+ * it always stands between blank lines and renders as a block, never as
2042
+ * backticks inside the sentence. A fence left unclosed keeps everything after
2043
+ * it and is closed here, so it cannot swallow the `more details` link the
2044
+ * card prints beneath it.
2045
+ */
2046
+ function proseBlocks(text) {
2047
+ const blocks = [];
2048
+ let paragraph = [];
2049
+ let fence = null;
2050
+ let fenced = [];
2051
+ for (const line of text.split('\n')) {
2052
+ if (fence !== null) {
2053
+ const closed = closesFence(line, fence);
2054
+ fenced.push(closed ? line.trim() : line.trimEnd());
2055
+ if (closed) {
2056
+ blocks.push(fenced.join('\n'));
2057
+ fence = null;
2058
+ }
2059
+ continue;
2060
+ }
2061
+ const opened = fenceOpening(line);
2062
+ if (opened !== null || line.trim() === '') {
2063
+ if (paragraph.length > 0)
2064
+ blocks.push(paragraph.join(' '));
2065
+ paragraph = [];
2066
+ }
2067
+ if (opened !== null) {
2068
+ fence = opened;
2069
+ fenced = [line.trim()];
2070
+ }
2071
+ else if (line.trim() !== '') {
2072
+ paragraph.push(line.trim());
2073
+ }
2074
+ }
2075
+ if (fence !== null)
2076
+ blocks.push([...fenced, fence.char.repeat(fence.length)].join('\n'));
2077
+ if (paragraph.length > 0)
2078
+ blocks.push(paragraph.join(' '));
2079
+ return blocks;
2080
+ }
2081
+ /** 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) {
2083
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
2084
+ return marker === undefined ? null : { char: marker.charAt(0), length: marker.length };
2085
+ }
2086
+ /** Whether a line closes an open fence: the same character, at least as many of it, and nothing after. */
2087
+ function closesFence(line, fence) {
2088
+ const marker = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
2089
+ return marker !== undefined && marker.startsWith(fence.char) && marker.length >= fence.length;
2008
2090
  }
2009
2091
  /**
2010
2092
  * The `## Summary` section of `.plumbbob/detail.md` and the numbered detail
@@ -2048,8 +2130,7 @@ export function normalizeDetailHeadings(detail) {
2048
2130
  let afterHeading = false;
2049
2131
  for (const line of detail.split('\n')) {
2050
2132
  if (fence !== null) {
2051
- const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
2052
- if (close !== undefined && close.startsWith(fence.char) && close.length >= fence.length) {
2133
+ if (closesFence(line, fence)) {
2053
2134
  fence = null;
2054
2135
  }
2055
2136
  out.push(line);
@@ -2060,10 +2141,7 @@ export function normalizeDetailHeadings(detail) {
2060
2141
  if ((heading || afterHeading) && line.trim() !== '' && prior !== undefined && prior.trim() !== '') {
2061
2142
  out.push('');
2062
2143
  }
2063
- const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
2064
- if (open !== undefined) {
2065
- fence = { char: open.charAt(0), length: open.length };
2066
- }
2144
+ fence = fenceOpening(line);
2067
2145
  out.push(line);
2068
2146
  afterHeading = heading;
2069
2147
  }
@@ -2101,8 +2179,9 @@ export function parseSections(detail) {
2101
2179
  }
2102
2180
  /**
2103
2181
  * The `## Recommendation` section of `.plumbbob/detail.md`: the one or two
2104
- * plain sentences a decision turn ends on, or null when the model wrote none.
2105
- * `handoff` emits it after the card, unfenced; the eye lands on the last text,
2182
+ * plain sentences a decision turn ends on, each shell command they name in a
2183
+ * `sh` fence of its own, or null when the model wrote none. `handoff` emits it
2184
+ * after the card, the prose itself unfenced; the eye lands on the last text,
2106
2185
  * and the last text should say which move the model would take.
2107
2186
  */
2108
2187
  export function parseRecommendation(detail) {
@@ -18,7 +18,6 @@
18
18
  // human-owned (check/auto): the tool only ever reads it, never rewrites it. The
19
19
  // phase the dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored: BUILD ⇔
20
20
  // a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
21
- import { createHash } from 'node:crypto';
22
21
  import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
23
22
  import { dirname, join } from 'node:path';
24
23
  import { gitPath } from "./git.js";
@@ -215,56 +214,85 @@ export function clearStep(root, slug) {
215
214
  rmSync(stepPath(root, slug), { force: true });
216
215
  }
217
216
  /**
218
- * PAUSE: the stamp of the step pause `handoff` last rendered whole, the step
219
- * number and a hash of what the card showed. While a later render matches it,
220
- * handoff prints the one-line reminder instead of the card.
217
+ * PAUSE: which card the human has already read, so a later `handoff` prints
218
+ * the one line that points back at it instead of the card. It holds a step
219
+ * number once that step's pause card has shown (`2`), or `landed 2` once the
220
+ * boundary step 2 landed on has shown.
221
+ *
222
+ * It records that a card showed, never what the card said: a later turn that
223
+ * changes the work is the model's reply, and the card comes back only on
224
+ * request (D110 (reply-after-the-card)).
221
225
  */
222
226
  export function pausePath(root, slug) {
223
227
  return join(artifactDir(root, slug), 'PAUSE');
224
228
  }
225
229
  /**
226
- * Remove the PAUSE stamp: a checkpoint, a revert, or an abandon ends the pause
227
- * it recorded, so the next pause renders whole. Absent is a no-op.
230
+ * The card PAUSE says has shown, or null when the file is absent or does not
231
+ * parse. A stamp written before D110 (reply-after-the-card) holds a step
232
+ * number and a hash, and reads as that step's pause.
233
+ */
234
+ export function readPause(root, slug) {
235
+ 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]) };
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ }
243
+ /**
244
+ * Record that a card has shown. Best-effort: a failed write costs one more
245
+ * full card on a later turn, never this turn's output or its exit code.
246
+ */
247
+ export function writePause(root, slug, stamp) {
248
+ try {
249
+ writeFileSync(pausePath(root, slug), `${stamp.kind === 'landed' ? 'landed ' : ''}${stamp.step}\n`);
250
+ }
251
+ catch {
252
+ // A read-only tree or a vanished build folder: the card still renders.
253
+ }
254
+ }
255
+ /**
256
+ * Remove the PAUSE stamp: a revert or an abandon ends the pause it recorded,
257
+ * so the next pause renders whole. Absent is a no-op.
228
258
  */
229
259
  export function clearPause(root, slug) {
230
260
  rmSync(pausePath(root, slug), { force: true });
231
261
  }
232
262
  /**
233
263
  * STOP: the stamp of the stop card `handoff --stop` last rendered whole, the
234
- * range it reviews, the TURN it printed in, a hash of what the card showed,
235
- * and a digest of the detail file it printed from. While a later render
236
- * matches the range and the hash, handoff prints the one-line reminder;
237
- * `plumbbob harvest` records the stop only from the detail the card showed,
238
- * and only once a human turn has read it.
264
+ * range it reviews and the TURN it printed in. While a later render names the
265
+ * same range, handoff prints the one-line reminder, whatever changed since;
266
+ * `plumbbob harvest` records the stop only once a card for the range has shown
267
+ * and a human turn has read it.
239
268
  */
240
269
  export function stopPath(root, slug) {
241
270
  return join(artifactDir(root, slug), 'STOP');
242
271
  }
243
272
  /**
244
273
  * The stamp of the stop card last rendered, or null when STOP is absent or
245
- * does not parse.
274
+ * does not parse. A stamp written before D110 (reply-after-the-card) carries
275
+ * a hash and a digest after the turn, and both are ignored.
246
276
  */
247
277
  export function readStop(root, slug) {
248
278
  try {
249
- const m = /^(\d+) (\d+|-) (\S+)(?: (\S+))?$/.exec(readFileSync(stopPath(root, slug), 'utf8').trim());
279
+ const m = /^(\d+) (\d+|-)(?:\s|$)/.exec(readFileSync(stopPath(root, slug), 'utf8').trim());
250
280
  if (m === null)
251
281
  return null;
252
- return { range: Number(m[1]), turn: m[2] === '-' ? null : Number(m[2]), hash: m[3] ?? '', detail: m[4] ?? null };
282
+ return { range: Number(m[1]), turn: m[2] === '-' ? null : Number(m[2]) };
253
283
  }
254
284
  catch {
255
285
  return null;
256
286
  }
257
287
  }
258
288
  /**
259
- * Record the stamp of the stop card just printed, with the digest of the
260
- * detail file as it stands, which is the file the card was rendered from.
261
- * Best-effort: a failed write costs one more full card on a later turn, never
262
- * this turn's output or its exit code.
289
+ * Record the stamp of the stop card just printed. Best-effort: a failed write
290
+ * costs one more full card on a later turn, never this turn's output or its
291
+ * exit code.
263
292
  */
264
293
  export function writeStop(root, slug, stamp) {
265
294
  try {
266
- const detail = detailDigest(readFileSync(detailPath(root), 'utf8'));
267
- writeFileSync(stopPath(root, slug), `${stamp.range} ${stamp.turn ?? '-'} ${stamp.hash} ${detail}\n`);
295
+ writeFileSync(stopPath(root, slug), `${stamp.range} ${stamp.turn ?? '-'}\n`);
268
296
  }
269
297
  catch {
270
298
  // A read-only tree or a vanished build folder: the card still renders.
@@ -277,13 +305,6 @@ export function writeStop(root, slug, stamp) {
277
305
  export function clearStop(root, slug) {
278
306
  rmSync(stopPath(root, slug), { force: true });
279
307
  }
280
- /**
281
- * The digest a STOP stamp keeps of a detail file, so the record can tell the
282
- * detail a card showed from one written after it.
283
- */
284
- export function detailDigest(detail) {
285
- return createHash('sha256').update(detail).digest('hex');
286
- }
287
308
  /**
288
309
  * The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
289
310
  * commits, one line each.
@@ -689,7 +710,7 @@ export function readTurn(root) {
689
710
  // ending: a decision ending closes the turn, an orientation ending appears in
690
711
  // it, and a driver ending is recorded only so that the last ending wins.
691
712
  const RELAY_TIERS = ['decision', 'orientation', 'driver'];
692
- // Which ending printed: handoff's eight forms first, then one per transition.
713
+ // Which ending printed: handoff's nine forms first, then one per transition.
693
714
  // It is what the check's reason names when the ending went missing.
694
715
  const RELAY_RENDERS = [
695
716
  'card',
@@ -700,6 +721,7 @@ const RELAY_RENDERS = [
700
721
  'boundary',
701
722
  'pointer',
702
723
  'driver',
724
+ 'harvest-card',
703
725
  'checkpoint',
704
726
  'plan-commit',
705
727
  'park',
@@ -911,10 +933,10 @@ export function excludeControl(root) {
911
933
  // The entry stamp is in-flight control like STEP/SEAM: checkpoint's
912
934
  // stageAll must never sweep it into a step commit.
913
935
  `${DIRNAME}/builds/*/TICK`,
914
- // The pause stamp is in-flight control too: what handoff last showed, so
915
- // a repeat prints the reminder. It must never ride a step commit.
936
+ // The pause stamp is in-flight control too: which card has shown, so a
937
+ // later handoff prints its one line. It must never ride a step commit.
916
938
  `${DIRNAME}/builds/*/PAUSE`,
917
- // The stop stamp likewise: what the stop card last showed, and the turn it
939
+ // The stop stamp likewise: the range whose card has shown, and the turn it
918
940
  // printed in, for the reminder and the record's latch.
919
941
  `${DIRNAME}/builds/*/STOP`,
920
942
  // The agent-run handoff ledger is step-scoped in-flight state, not a
@@ -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, clearPause, clearTick, detailPath, hasSession, intentPath, readRun, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.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";
16
16
  import { runCheck } from "../lib/check.js";
17
17
  import { readCommitBody } from "../lib/commitbody.js";
18
18
  import { checkLatch } from "../lib/latch.js";
@@ -22,8 +22,7 @@ import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commit
22
22
  import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
23
23
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
24
24
  import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
25
- import { boundaryEnding, detailsLink, parseDetailRange, planRecord, stepRecord } from "./handoff.js";
26
- import { isHarvestDetail } from "./harvest.js";
25
+ import { boundaryEnding, detailsLink, isHarvestDetail, parseDetailRange, planRecord, stepRecord } from "./handoff.js";
27
26
  /**
28
27
  * Land a step: latch, check gate, commit, record, return to the boundary.
29
28
  *
@@ -122,11 +121,14 @@ export async function checkpoint(cwd, args) {
122
121
  rmSync(stepPath(root), { force: true });
123
122
  clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
124
123
  clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
125
- clearPause(root); // the pause it stamped is over; the next step's pause renders whole.
126
- // The whole ending, one block: the lead line, the Verdict folded over what
127
- // just landed, the advisories this checkpoint alone can state, and the
128
- // pointer. Read after the flip and the ledger append, so the fold and the
129
- // pointer both see the boundary the human is standing at.
124
+ // The pause it stamped is over, and the boundary this ending prints is the
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 });
128
+ // The whole ending, one block: the lead line, the advisories this checkpoint
129
+ // alone can state, and the state table headed by the Verdict folded over
130
+ // what just landed. Read after the flip and the ledger append, so the fold
131
+ // and the pointer both see the boundary the human is standing at.
130
132
  const landed = boundaryEnding(root, activeBuild(root), step);
131
133
  // A landing that completes the range a typed run marked owes the stop card:
132
134
  // the record says so, and the relay check holds a turn that ends here.
@@ -137,10 +139,8 @@ export async function checkpoint(cwd, args) {
137
139
  ...(owes === null ? {} : { owes }),
138
140
  text: ending({
139
141
  lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
140
- verdict: landed.verdict,
141
142
  advisories: [drift, flipNotice],
142
- stop: landed.stop,
143
- pointer: landed.pointer,
143
+ state: landed.state,
144
144
  }),
145
145
  });
146
146
  return 0;
@@ -202,7 +202,7 @@ function checkpointPlan(root, args) {
202
202
  remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
203
203
  }),
204
204
  ],
205
- pointer: boundaryEnding(root, activeBuild(root), null).pointer,
205
+ state: boundaryEnding(root, activeBuild(root), null).state,
206
206
  }),
207
207
  });
208
208
  return 0;