plumbbob 0.12.1 → 0.13.1

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.12.1",
4
+ "version": "0.13.1",
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
@@ -131,8 +131,8 @@ In Claude Code, inside any git repo with a clean tree:
131
131
 
132
132
  1. **Plan.** Fire `/plumbbob:plan` and give it whatever you have: nothing (it interviews
133
133
  you), a rough line (`/plumbbob:plan rate-limit POST /login, 5/min/IP, return 429`), or a
134
- path to a spec file. Together you fill the build's `intent.md`. No code is written
135
- yet.
134
+ path to a spec file. Together you fill the build's `intent.md`: the steps, and the
135
+ build ranges that say where the build stops for your call. No code is written yet.
136
136
  2. **Build.** Fire `/plumbbob:build`. It implements the next undone step, runs the
137
137
  heavy check gate, reviews its own diff against the plan, and stops. What it stops on
138
138
  is one block with a fixed shape (the [turn anatomy](docs/presentation.md)): the CLI
@@ -140,12 +140,14 @@ In Claude Code, inside any git repo with a clean tree:
140
140
  nothing is written around it.
141
141
 
142
142
  ````markdown
143
+ **Now**: Step 1 of 3 - feat(limiter): add a token-bucket limiter
144
+
143
145
  **Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute. (details: `.plumbbob/detail.md`)
144
146
 
145
147
  1. The bucket lives in memory, keyed by IP, refilling five tokens a minute.
146
148
  2. The clock is injected, so the test advances time instead of sleeping on it.
147
149
 
148
- **Readout**: Step 1 - feat(limiter): add a token-bucket limiter
150
+ **Readout**:
149
151
 
150
152
  ```text
151
153
  check green: 7 of 7 checks
@@ -164,7 +166,7 @@ In Claude Code, inside any git repo with a clean tree:
164
166
  **Your Call**:
165
167
 
166
168
  - `looks good` → I checkpoint Step 1; back to the boundary
167
- - `expand`, or any question → I show more of what is there; nothing changes
169
+ - `expand`, or any question → I show more of what is there; nothing changes, and `show the pause` brings all of this back
168
170
  - anything that reads as direction → I take it as what to change; nothing lands until you approve
169
171
  - `revert` → I wind the work back to the last checkpoint
170
172
 
@@ -192,9 +194,13 @@ In Claude Code, inside any git repo with a clean tree:
192
194
  ![The boundary after looks good](docs/media/checkpoint-boundary.svg)
193
195
 
194
196
  Ask instead (`expand 2`, or any question) and it shows more of what is there and
195
- changes nothing; direct it and it takes that as the fix, and nothing lands until you
196
- say `looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
197
- *is* the clock tick. Whenever you lose the thread, `/plumbbob:status` shows where you
197
+ changes nothing. The answer closes on one line pointing back at the step, not on the
198
+ whole block again, since you have already read it; say `show the pause` to see the
199
+ block again. Direct it and it takes that as the fix, and nothing lands until you say
200
+ `looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
201
+ *is* the clock tick. When the plan cut build ranges, the line beneath Next Up names
202
+ one to type instead (`· range 2 of 2: /plumbbob:build 2-3`): it lands each step it
203
+ names and stops where the plan reserved a call for you. Whenever you lose the thread, `/plumbbob:status` shows where you
198
204
  are and names the next move.
199
205
  4. **Finish.** When the last step is done, `/plumbbob:finish` writes a report of what shipped
200
206
  and why into the build's tracked folder, makes the final commit, and clears the slate
@@ -220,8 +226,8 @@ nothing else:
220
226
 
221
227
  | Skill <img alt="" width="110" height="1"> | Does |
222
228
  | ------------------------------------ | ------ |
223
- | `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps** |
224
- | `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3` self-approves through step 3, then stops |
229
+ | `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps**, with the build ranges that say where the build stops for you |
230
+ | `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3`, or a list like `20,5-9`, self-approves exactly the steps it names, then stops |
225
231
  | `/plumbbob:finish` | finish up: write the report, make the final commit, clear for a fresh goal |
226
232
 
227
233
  **Plan-shaping moves**: optional, for when the plan needs work mid-flight:
@@ -229,7 +235,7 @@ nothing else:
229
235
  | Skill <img alt="" width="110" height="1"> | Does |
230
236
  | ------------------------------------ | ------ |
231
237
  | `/plumbbob:step` | revise/sharpen the next step (empty input auto-syncs it to reality) |
232
- | `/plumbbob:refine` | attack the frame for holes, or repair a drifted plan |
238
+ | `/plumbbob:refine` | attack the frame for holes, or repair a drifted plan (`ranges` re-cuts the build ranges) |
233
239
  | `/plumbbob:spike` | throwaway worktree experiment for a fork the plan can't settle |
234
240
 
235
241
  **Helpers**: orient, verify, recover, diagnose:
@@ -254,9 +260,10 @@ All fourteen, with inputs and effects, are in
254
260
  [`docs/skills-reference.md`](docs/skills-reference.md).
255
261
 
256
262
  Under the skills ships a lean `plumbbob` CLI (the mechanical verbs the
257
- skills shell out to), three hooks (a non-blocking post-edit lint, a `git commit`
258
- ask-hook while a step is in flight, and the per-prompt turn ledger the approval latch
259
- reads), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
263
+ skills shell out to), four hooks (a non-blocking post-edit lint, a `git commit`
264
+ ask-hook while a step is in flight, the per-prompt turn ledger the approval latch
265
+ reads, and a check at each turn's end that the turn relayed the ending plumbbob
266
+ printed), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
260
267
  `builds/<slug>/` folder per build (`intent.md`, `build-log.md`, `checkpoints`, `report.md`)
261
268
  that rides the branch into the PR, plus an untracked control plane (`settings.local.json`,
262
269
  the session sentinel, the in-flight markers).
package/dist/cli-core.js CHANGED
@@ -28,6 +28,7 @@ import { init } from "./verbs/init.js";
28
28
  import { doctor } from "./verbs/doctor.js";
29
29
  import { recover } from "./verbs/recover.js";
30
30
  import { agent } from "./verbs/agent.js";
31
+ import { relay } from "./verbs/relay.js";
31
32
  import { turn } from "./verbs/turn.js";
32
33
  /** Shared across every verb that resolves through the active-build cursor. */
33
34
  const BUILD_FLAG = {
@@ -80,15 +81,16 @@ const VERBS = [
80
81
  },
81
82
  {
82
83
  name: 'handoff',
83
- description: 'print the footer card that ends a turn (pause, boundary, plan, or driver); read-only',
84
- synopsis: ['handoff [<n>] [--plan] [--driver] [--build <slug>]'],
84
+ description: 'print the footer card that ends a turn (pause, boundary, plan, or driver), or a reminder for a pause already shown',
85
+ synopsis: ['handoff [<n>] [--full] [--plan] [--driver] [--build <slug>]'],
85
86
  args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
86
87
  flags: [
88
+ { name: '--full', gloss: 'print the whole pause card even when that pause was already shown' },
87
89
  { name: '--plan', gloss: 'render the plan-pause card instead of a step tier' },
88
90
  { name: '--driver', gloss: "render a driver turn's pointer back at the open step" },
89
91
  BUILD_FLAG,
90
92
  ],
91
- notes: 'Read-only; refuses (exit 1) with no session, or with --plan and --driver together.',
93
+ notes: 'Writes only the detail file (a blank line around each heading) and the PAUSE stamp; refuses (exit 1) with no session, or with --plan and --driver together.',
92
94
  },
93
95
  {
94
96
  name: 'check',
@@ -144,10 +146,15 @@ const VERBS = [
144
146
  {
145
147
  name: 'order',
146
148
  description: 'set the build order: the undone steps in the sequence to build them',
147
- synopsis: ['order <n> [<n>…]', 'order --reset'],
148
- args: [{ name: '<n>…', gloss: 'undone step numbers in the sequence to build them; the rest follow in document order' }],
149
+ synopsis: ['order <steps>', 'order --reset'],
150
+ args: [
151
+ {
152
+ name: '<steps>',
153
+ gloss: 'undone step numbers and spans (2-4) in the sequence to build them, space- or comma-separated (1, 6, 2-4, 7, 5); the rest follow in document order',
154
+ },
155
+ ],
149
156
  flags: [{ name: '--reset', gloss: 'drop the line, so the plan reads in document order again' }],
150
- notes: 'Writes the one numeric line under `## Build order` in intent.md, which status, build, checkpoint, and handoff all read. Refuses (exit 1) with no session, no numbers, a number the plan lacks, a repeat, or a step already checkpointed.',
157
+ notes: 'Writes the one numeric line under `## Build order` in intent.md, which status, build, checkpoint, and handoff all read. A span is spelled out on the line. Refuses (exit 1) with no session, no numbers, a span that runs backward, a number the plan lacks, a repeat, or a step already checkpointed.',
151
158
  },
152
159
  {
153
160
  name: 'spike',
@@ -225,6 +232,13 @@ const VERBS = [
225
232
  tolerateUnknownFlags: true,
226
233
  notes: 'Reads the hook payload from stdin and always exits 0 — a broken hook must never wedge a prompt.',
227
234
  },
235
+ {
236
+ name: 'relay',
237
+ description: 'Stop hook machinery: check that the turn carried the ending it printed (not a user verb)',
238
+ synopsis: ['relay'],
239
+ tolerateUnknownFlags: true,
240
+ notes: "Reads the hook payload from stdin and always exits 0: a broken hook must never wedge a turn's end.",
241
+ },
228
242
  ];
229
243
  /**
230
244
  * Render the verb table for `plumbbob help`.
@@ -379,6 +393,8 @@ async function dispatch(verb, cwd, rest) {
379
393
  return agent(cwd, rest);
380
394
  case 'turn':
381
395
  return turn(cwd, rest);
396
+ case 'relay':
397
+ return relay(cwd, rest);
382
398
  default:
383
399
  process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
384
400
  return 1;
package/dist/lib/git.js CHANGED
@@ -68,18 +68,30 @@ export function hasCommit(root) {
68
68
  export function isDirty(root) {
69
69
  return runGit(root, ['status', '--porcelain']).length > 0;
70
70
  }
71
+ /**
72
+ * The body line that marks a plan commit made by hand (a `chore(plan)`
73
+ * harvest). A harvest lands between nearly every step, so counting it as
74
+ * out-of-band would trip the advisory on routine housekeeping and stop meaning
75
+ * anything within three steps. `status` and `handoff` both pass it to
76
+ * `commitsSince`, which is what keeps the two counts one quantity.
77
+ */
78
+ export const PLAN_COMMIT_MARKER = '^plumbbob plan$';
71
79
  /**
72
80
  * The number of commits on HEAD not reachable from `sha`: `git rev-list
73
81
  * --count --first-parent <sha>..HEAD`.
74
82
  *
75
- * `status` reads this for its receipts line: one neutral note when commits
76
- * landed since the last checkpoint outside plumbbob's ledger (out-of-band
77
- * commits are surfaced, never blocked). `--first-parent` keeps the count on
78
- * the branch's own line: merging upstream reads as the one merge commit, not
79
- * the dozens it carried; those didn't land "outside the ledger" in any sense
80
- * the receipt should nag about. `excluding` is a commit-message pattern whose
81
- * matches are left out of the count, which is how a caller drops plumbbob's own
82
- * commits from a tally of what landed around them.
83
+ * `status` and `handoff` both read this for their out-of-band count: one
84
+ * neutral note when commits landed since the last checkpoint outside
85
+ * plumbbob's ledger (out-of-band commits are surfaced, never blocked). Two
86
+ * mechanisms keep plumbbob's own commits out of that count. A commit any
87
+ * plumbbob verb makes is recorded in the ledger and becomes the anchor, so the
88
+ * range starts after it. A plan commit made by hand carries
89
+ * `PLAN_COMMIT_MARKER` on a line of its body, and both callers pass it as
90
+ * `excluding` so the range drops it. `--first-parent` keeps the count on the
91
+ * branch's own line: merging upstream reads as the one merge commit, not the
92
+ * dozens it carried; those didn't land "outside the ledger" in any sense the
93
+ * receipt should nag about. `excluding` is a commit-message pattern whose
94
+ * matches are left out of the count.
83
95
  *
84
96
  * Best-effort and never throws: 0 when the range is empty, `sha` is unknown to
85
97
  * the repo, or HEAD is unborn: the count is informational (the human commits
package/dist/lib/latch.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import { readFileSync } from 'node:fs';
18
18
  import { grantPath, tickPath, turnPath } from "./sidecar.js";
19
19
  import { resolveBoolean } from "./settings.js";
20
+ import { hasStep, normalizeStepList, parseStepList } from "./steplist.js";
20
21
  const ALLOW = { allow: true };
21
22
  /**
22
23
  * The refusal message is the affordance; it tells the model what the pause is,
@@ -51,12 +52,20 @@ const SETTINGS_AUTO_NOTE = `note: \`auto\` is set in settings but is no longer a
51
52
  function ceilingMessage(ceiling) {
52
53
  return `plumbbob: checkpoint refused — the range you granted ends at step ${ceiling} — pause here; run it again to continue.\n`;
53
54
  }
55
+ /**
56
+ * The refusal for a step a list grant does not name, naming the steps it does.
57
+ */
58
+ function outsideMessage(step, steps) {
59
+ return `plumbbob: checkpoint refused — step ${step} is not in the range you granted (${normalizeStepList(steps)}) — pause here; run it again to continue.\n`;
60
+ }
54
61
  /**
55
62
  * The five-row matrix, in order, first hit wins.
56
63
  *
57
- * A `range` grant speaks only to numbered steps: on a `--plan` checkpoint (step
64
+ * A range grant speaks only to numbered steps: on a `--plan` checkpoint (step
58
65
  * null) it neither allows nor refuses, and the decision falls through to the
59
- * turn comparison.
66
+ * turn comparison. A `steps` grant lands exactly the steps the human typed and
67
+ * refuses any other, so no later edit to the plan or its build order can widen
68
+ * it; both kinds refuse with the `ceiling` reason, a step outside the range.
60
69
  */
61
70
  export function evaluateLatch(input) {
62
71
  if (input.isTTY)
@@ -65,6 +74,11 @@ export function evaluateLatch(input) {
65
74
  return ALLOW; // 2: dormant ledger / hand-built diff
66
75
  if (input.grant?.kind === 'auto')
67
76
  return ALLOW; // 3: one-turn grant the human typed
77
+ if (input.grant?.kind === 'steps' && input.step !== null) {
78
+ if (hasStep(input.grant.steps, input.step))
79
+ return ALLOW;
80
+ return { allow: false, reason: 'ceiling', message: outsideMessage(input.step, input.grant.steps) };
81
+ }
68
82
  if (input.grant?.kind === 'range' && input.step !== null) {
69
83
  if (input.step <= input.grant.ceiling)
70
84
  return ALLOW;
@@ -78,15 +92,19 @@ export function evaluateLatch(input) {
78
92
  return { allow: false, reason: 'no-turn', message: input.settingsAuto ? base + SETTINGS_AUTO_NOTE : base };
79
93
  }
80
94
  /**
81
- * Parse a GRANT file's content (`auto` | `range <M>`).
95
+ * Parse a GRANT file's content (`auto` | `steps <list>` | `range <M>`).
82
96
  *
83
- * Anything else (including a corrupted or hand-mangled file) contributes
84
- * nothing: no grant, never an error.
97
+ * Anything else (including a corrupted or hand-mangled file, or a list that
98
+ * does not parse) contributes nothing: no grant, never an error.
85
99
  */
86
100
  export function parseGrant(raw) {
87
101
  const trimmed = raw.trim();
88
102
  if (trimmed === 'auto')
89
103
  return { kind: 'auto' };
104
+ const listed = /^steps (\S+)$/.exec(trimmed);
105
+ const steps = listed === null ? null : parseStepList(listed[1] ?? '');
106
+ if (steps !== null)
107
+ return { kind: 'steps', steps };
90
108
  const range = /^range (\d+)$/.exec(trimmed);
91
109
  if (range !== null)
92
110
  return { kind: 'range', ceiling: Number(range[1]) };
@@ -11,7 +11,10 @@
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. Pure string assembly, no fs, no gate.
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.
17
+ import { writeRelay } from "./sidecar.js";
15
18
  /** The column budget a notice line aims to fit, matching the readout fence. */
16
19
  const WIDTH = 80;
17
20
  /** The fewest detail items a degraded parenthetical still names, so the count has a universe to size. */
@@ -64,13 +67,14 @@ export function advisory(a) {
64
67
  }
65
68
  /**
66
69
  * Assemble a transition's whole ending: the labeled lead line, the Verdict
67
- * where one was measured, the advisories, and the pointer.
70
+ * where one was measured, the advisories, the Stop part where a range landed,
71
+ * and the pointer.
68
72
  *
69
73
  * The order is the type's field order, so a verb composes parts and never a
70
74
  * sequence. A part with nothing in it vanishes rather than leaving a gap.
71
75
  */
72
76
  export function ending(e) {
73
- return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.pointer ?? null]);
77
+ return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.stop ?? null, e.pointer ?? null]);
74
78
  }
75
79
  /**
76
80
  * Stack a turn's parts: one blank line between each, one beneath the last.
@@ -83,6 +87,17 @@ export function blocks(parts) {
83
87
  const kept = parts.filter((p) => p !== null && p.trim().length > 0);
84
88
  return `${kept.map((p) => p.replace(/\n+$/, '')).join('\n\n')}\n\n`;
85
89
  }
90
+ /**
91
+ * Print an ending to stdout and record it in RELAY, for the Stop check that
92
+ * the turn closed on it.
93
+ *
94
+ * Stdout first, and the record never throws: a failed write leaves the
95
+ * printed ending and the verb's exit code exactly as they would have been.
96
+ */
97
+ export function printEnding(root, relay) {
98
+ process.stdout.write(relay.text);
99
+ writeRelay(root, relay);
100
+ }
86
101
  /**
87
102
  * The head, its trailing parenthetical, and the remedy line beneath, assembled.
88
103
  *