plumbbob 0.8.0 → 0.8.3

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.8.0",
4
+ "version": "0.8.3",
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",
@@ -9,9 +9,9 @@
9
9
  //
10
10
  // Known keys: `check` (string — the heavy gate, tracked in settings.json) and
11
11
  // `auto` (boolean — whether the agent approves in the human's place; a personal
12
- // preference, so it belongs in settings.local.json). `activeBuild` (the
13
- // per-worktree cursor) also lives in settings.local.json but is resolved by
14
- // sidecar.ts, not here.
12
+ // preference, so it belongs in settings.local.json). The per-worktree active-build
13
+ // cursor is NOT here — it lives in the STATE file (see sidecar.ts), so this overlay
14
+ // stays purely human-owned and the tool never rewrites it.
15
15
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join } from 'node:path';
17
17
  const DIRNAME = '.plumbbob';
@@ -70,12 +70,6 @@ export function resolveRecord(root, key) {
70
70
  const value = resolveSetting(root, key, undefined);
71
71
  return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
72
72
  }
73
- // Read one key from the untracked local overlay ONLY — no project or built-in
74
- // fallback. The `activeBuild` cursor lives here and must never resolve from the
75
- // tracked settings.json (it is per-worktree state, not a shared default).
76
- export function localSetting(root, key) {
77
- return readSettings(localSettingsPath(root))[key];
78
- }
79
73
  // Merge one key into settings.local.json, preserving the other keys and creating
80
74
  // the file when absent. Pretty-printed so the overlay stays hand-editable. A
81
75
  // malformed existing file contributes nothing (readSettings yields {}) and is
@@ -2,14 +2,18 @@
2
2
  // read it with a grep and no markdown parsing (D7). Functional/procedural,
3
3
  // node builtins only (C1/C2).
4
4
  //
5
- // STATE is a pure session sentinel: its EXISTENCE means "a session is active",
6
- // and nothing reads its content. The phase the dashboard shows (DESIGN/BUILD/
7
- // SPIKE) is derived, not stored — BUILD ⇔ a STEP is in flight, SPIKE ⇔ the SPIKE
8
- // marker is present, otherwise DESIGN.
5
+ // STATE is the session sentinel AND the active-build cursor (D28): its EXISTENCE
6
+ // means "a session is active", and its CONTENT names the build that session is on —
7
+ // the two reinforce each other (a session is always on some build; the cursor is
8
+ // meaningless without a session). Content is empty under --local / no build.
9
+ // hasSession stays existence-only; activeBuild reads the content. Homing the cursor
10
+ // here — not in settings.local.json — keeps that overlay purely human-owned
11
+ // (check/auto): the tool only ever reads it, never rewrites it. The phase the
12
+ // dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored — BUILD ⇔ a STEP is
13
+ // in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
9
14
  import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
10
15
  import { dirname, join } from 'node:path';
11
16
  import { gitPath } from "./git.js";
12
- import { localSetting } from "./settings.js";
13
17
  const DIRNAME = '.plumbbob';
14
18
  export function sidecarDir(root) {
15
19
  return join(root, DIRNAME);
@@ -50,19 +54,35 @@ export function listBuilds(root) {
50
54
  }
51
55
  }
52
56
  // Resolve which build a verb acts on (D28): an explicit `--build <slug>` flag →
53
- // the `activeBuild` cursor in settings.local.json → the sole build in `builds/`
54
- // → null (the caller then refuses with a hint). One-active-per-worktree holds by
55
- // construction: the cursor is a single scalar key in an untracked file, so it can
56
- // never point at two builds (D28).
57
+ // the cursor in STATE → the sole build in `builds/` → null (the caller then refuses
58
+ // with a hint). One-active-per-worktree holds by construction: the cursor is STATE's
59
+ // single-line content, so it can never point at two builds (D28).
57
60
  export function activeBuild(root, flag) {
58
61
  if (flag !== undefined && flag.length > 0)
59
62
  return flag;
60
- const cursor = localSetting(root, 'activeBuild');
61
- if (typeof cursor === 'string' && cursor.length > 0)
63
+ const cursor = readCursor(root);
64
+ if (cursor !== null)
62
65
  return cursor;
63
66
  const builds = listBuilds(root);
64
67
  return builds.length === 1 ? (builds[0] ?? null) : null;
65
68
  }
69
+ // STATE's content is the active-build cursor. Empty means "no build" (--local, or a
70
+ // session whose cursor is unset) → null, so activeBuild falls through to the
71
+ // sole-build rule. A pre-STATE-cursor session wrote the literal `active` sentinel
72
+ // here (the cursor lived in settings.local.json then); treat that legacy value as
73
+ // unset so an in-flight upgrade degrades gracefully instead of chasing a build named
74
+ // "active". `active` is thus reserved and never a valid cursor — no derived slug
75
+ // collides (they carry a YYYY-MM-DD- prefix).
76
+ function readCursor(root) {
77
+ let content;
78
+ try {
79
+ content = readFileSync(statePath(root), 'utf8').trim();
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ return content.length > 0 && content !== 'active' ? content : null;
85
+ }
66
86
  // The build a verb should act on, plus its argv with the `--build <slug>` pair
67
87
  // stripped (D28). Every verb resolves through this: an explicit `--build <slug>`
68
88
  // wins, else the cursor / sole-build fallback of `activeBuild`. `rest` matters
@@ -197,12 +217,26 @@ function patchStepStat(root, slug, step, patch) {
197
217
  }
198
218
  // A session exists iff STATE exists. Deleting STATE (at finish) is what flips the
199
219
  // repo back to "no session" — so it is the single source of truth for "is there
200
- // a session". `start` calls beginSession; `finish` removes the file.
220
+ // a session". `start` calls beginSession; `finish` removes the file. Existence is
221
+ // the whole session signal; STATE's content is a separate axis (the cursor).
201
222
  export function hasSession(root) {
202
223
  return existsSync(statePath(root));
203
224
  }
204
- export function beginSession(root) {
205
- writeFileSync(statePath(root), 'active\n');
225
+ // Open the session and point its cursor at `slug` — null under --local / no build,
226
+ // which leaves STATE present but empty so activeBuild falls through to the sole-build
227
+ // rule. `finish` removes the file, closing the session and clearing the cursor in
228
+ // one delete.
229
+ export function beginSession(root, slug = null) {
230
+ writeCursor(root, slug);
231
+ }
232
+ // Re-point the cursor at an existing build (`use`) — a plain content rewrite that
233
+ // leaves the session sentinel (STATE's existence) intact. Callers guard hasSession
234
+ // first, so this never resurrects a finished session.
235
+ export function setActiveBuild(root, slug) {
236
+ writeCursor(root, slug);
237
+ }
238
+ function writeCursor(root, slug) {
239
+ writeFileSync(statePath(root), slug === null ? '' : `${slug}\n`);
206
240
  }
207
241
  // SPIKE marker helpers — existence is the whole signal (content is irrelevant).
208
242
  export function inSpike(root, slug) {
@@ -309,10 +343,11 @@ function addExcludes(root, patterns) {
309
343
  appendFileSync(exclude, `${prefix}${missing.join('\n')}\n`);
310
344
  }
311
345
  // The narrowed control plane (D17): with `builds/<slug>/` now tracked, only the
312
- // per-worktree control files stay git-excluded — the local settings overlay (its
313
- // `activeBuild` cursor), the session sentinel, and the in-flight step markers
314
- // inside every build. Everything else under `.plumbbob/` (settings.json, and each
315
- // build's intent/build-log/checkpoints/report) rides the branch into the PR.
346
+ // per-worktree control files stay git-excluded — the session sentinel STATE (whose
347
+ // content is the active-build cursor, D28), the personal settings overlay, and the
348
+ // in-flight step markers inside every build. Everything else under `.plumbbob/`
349
+ // (settings.json, and each build's intent/build-log/checkpoints/report) rides the
350
+ // branch into the PR.
316
351
  export function excludeControl(root) {
317
352
  addExcludes(root, [
318
353
  `${DIRNAME}/STATE`,
@@ -31,8 +31,8 @@ import { listAgents } from "../lib/agents.js";
31
31
  import { gateDetectsTools } from "../lib/check.js";
32
32
  import { marketplacePlumbbob } from "../lib/plugins.js";
33
33
  import { findRepoRoot, gitPath, stagePath } from "../lib/git.js";
34
- import { buildDir, excludeControl, listBuilds, readTurn, sidecarDir, slugify } from "../lib/sidecar.js";
35
- import { resolveString, settingsPath, setLocalSetting } from "../lib/settings.js";
34
+ import { buildDir, excludeControl, listBuilds, readTurn, setActiveBuild, sidecarDir, slugify } from "../lib/sidecar.js";
35
+ import { resolveString, settingsPath } from "../lib/settings.js";
36
36
  // The plumbbob package's own skills/ dir (the canonical set), off this module's
37
37
  // URL so it resolves the same from src/ (dev) and dist/ (published).
38
38
  function packageDir(name) {
@@ -206,8 +206,12 @@ export function migrateSidecar(root) {
206
206
  const configPath = join(dir, 'config');
207
207
  if (existsSync(configPath)) {
208
208
  if (!existsSync(settingsPath(root))) {
209
+ // Carry forward ONLY what the legacy config actually held — a `check` line,
210
+ // nothing else. No invented `auto` (the config never had one, it defaults to
211
+ // false, and it belongs in settings.local.json): migration yields exactly what
212
+ // a fresh `start` would, plus the check. An empty config → an empty `{}`.
209
213
  const check = configCheck(configPath);
210
- const settings = check === null ? { auto: false } : { check, auto: false };
214
+ const settings = check === null ? {} : { check };
211
215
  writeFileSync(settingsPath(root), `${JSON.stringify(settings, null, 2)}\n`);
212
216
  actions.push(check === null ? 'config → settings.json' : `config → settings.json (check: ${check})`);
213
217
  }
@@ -220,7 +224,7 @@ export function migrateSidecar(root) {
220
224
  const slug = uniqueSlug(slugify(titleFromIntent(flatIntent)), taken);
221
225
  taken.add(slug);
222
226
  moveInto(dir, buildDir(root, slug), ['intent.md', 'build-log.md', 'checkpoints', 'STEP', 'SEAM', 'SPIKE']);
223
- setLocalSetting(root, 'activeBuild', slug);
227
+ setActiveBuild(root, slug);
224
228
  actions.push(`active session → builds/${slug} (the cursor)`);
225
229
  }
226
230
  // archive/<slug> → builds/<slug>. These are "done" by simply not being the cursor.
@@ -9,7 +9,6 @@ import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
11
11
  import { checkpointsPath, clearTick, hasSession, intentPath, readStats, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
12
- import { setLocalSetting } from "../lib/settings.js";
13
12
  import { parseTitle } from "../lib/orient.js";
14
13
  export function finish(cwd, args = []) {
15
14
  const root = findRepoRoot(cwd);
@@ -35,9 +34,10 @@ export function finish(cwd, args = []) {
35
34
  stageAll(root);
36
35
  }
37
36
  const sha = commit(root, subject(root, slug), bodyArg(args) ?? undefined);
38
- // Clear the control state: the in-flight markers, the per-worktree cursor (D28),
39
- // and the session sentinel (STATE last, so "no session" flips exactly at the end).
40
- // The tracked artifacts stay in place — only the ephemera go.
37
+ // Clear the control state: the in-flight markers first, then the session sentinel
38
+ // last (so "no session" flips exactly at the end). Deleting STATE also drops the
39
+ // cursor (D28) — cursor and session share the one file now, so a single delete does
40
+ // both. The tracked artifacts stay in place — only the ephemera go.
41
41
  rmSync(seamPath(root, slug), { force: true });
42
42
  rmSync(stepPath(root, slug), { force: true });
43
43
  rmSync(spikePath(root, slug), { force: true });
@@ -47,12 +47,6 @@ export function finish(cwd, args = []) {
47
47
  // the next session's first landing.
48
48
  clearTick(root, slug);
49
49
  setGrant(root, null);
50
- if (slug !== null) {
51
- // Drop the activeBuild key — JSON.stringify omits an `undefined` value, so the
52
- // cursor is removed while the other local settings (auto, …) survive. Skipped
53
- // under `--local`, where there is no cursor to clear.
54
- setLocalSetting(root, 'activeBuild', undefined);
55
- }
56
50
  rmSync(join(sidecarDir(root), 'STATE'), { force: true });
57
51
  const where = slug === null ? '.plumbbob/' : `.plumbbob/builds/${slug}/`;
58
52
  process.stdout.write(`plumbbob: finished — ${sha.slice(0, 9)}. ${where} rides your branch into the PR. ` +
@@ -1,12 +1,12 @@
1
1
  // `plumbbob start "<title>"` — scaffold the sidecar, record the baseline, open
2
2
  // the session. Refuses on a dirty tree (D22), an existing session, or a non-git dir.
3
3
  import { fileURLToPath } from 'node:url';
4
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { detectGate } from "../lib/check.js";
7
7
  import { findRepoRoot, hasCommit, headSha, isDirty } from "../lib/git.js";
8
8
  import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
9
- import { settingsPath, setLocalSetting } from "../lib/settings.js";
9
+ import { settingsPath } from "../lib/settings.js";
10
10
  // D24/D32: the gate is checkride unless a `check` setting overrides it, so
11
11
  // settings.json seeds with no `check` key at all — absence IS the default. The
12
12
  // templates' {{CHECK}} line is a human-readable echo of that.
@@ -51,8 +51,20 @@ export async function start(cwd, args) {
51
51
  }
52
52
  const sha = headSha(root);
53
53
  mkdirSync(sidecarDir(root), { recursive: true });
54
- beginSession(root);
55
- writeFileSync(settingsPath(root), `${JSON.stringify({ auto: false }, null, 2)}\n`);
54
+ // beginSession writes STATE — the session sentinel and, in its content, the cursor
55
+ // (D28). Point it at this build (null under --local, which has no cursor). The
56
+ // cursor lives in STATE, not settings.local.json, so that overlay stays human-owned.
57
+ beginSession(root, local ? null : slug);
58
+ // Scaffold the tracked settings.json ONLY when absent, and seed it EMPTY — the
59
+ // human owns this file once it exists (their `check` gate lives here), so a
60
+ // re-start must never touch it. We inject no opinions even on first create:
61
+ // absence of `check` already means checkride and absence of `auto` already means
62
+ // false (see settings.ts), so `{}` is exactly "all defaults". `auto` in
63
+ // particular is a personal preference that belongs in settings.local.json, not
64
+ // this tracked file — seeding it here was the bug that clobbered custom checks.
65
+ if (!existsSync(settingsPath(root))) {
66
+ writeFileSync(settingsPath(root), `${JSON.stringify({}, null, 2)}\n`);
67
+ }
56
68
  // D26: `--local` keeps today's fully-untracked flat layout (whole `.plumbbob/`
57
69
  // excluded); the default plants a tracked, PR-riding `builds/<slug>/` folder
58
70
  // (D26) and points the per-worktree cursor at it (D28), excluding only control
@@ -72,7 +84,6 @@ export async function start(cwd, args) {
72
84
  writeFileSync(join(dir, 'checkpoints'), `baseline ${sha}\n`);
73
85
  writeFileSync(join(dir, 'intent.md'), stamp(readTemplate('intent.md'), title, CHECK_ECHO));
74
86
  writeFileSync(join(dir, 'build-log.md'), stamp(readTemplate('build-log.md'), title, CHECK_ECHO));
75
- setLocalSetting(root, 'activeBuild', slug);
76
87
  excludeControl(root);
77
88
  intentLocation = `.plumbbob/builds/${slug}/intent.md`;
78
89
  }
package/dist/verbs/use.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // `plumbbob use <slug>` (D30) — re-point the per-worktree cursor at an existing
2
2
  // build and resume it. Both switching and resuming are the same one word (Q10,
3
- // `nvm use`-shaped): the cursor is a single scalar key in settings.local.json, so
4
- // pointing it elsewhere IS the switch and one-active-per-worktree holds by
3
+ // `nvm use`-shaped): the cursor is STATE's single-line content, so pointing it
4
+ // elsewhere IS the switch and one-active-per-worktree holds by
5
5
  // construction (D28). It validates the target folder exists, and warns — but
6
6
  // allows — leaving a build with a step in flight: that surviving in-flight state
7
7
  // is D26's whole payoff, resumed the next time you `use` that build.
8
8
  import { existsSync } from 'node:fs';
9
9
  import { findRepoRoot } from "../lib/git.js";
10
- import { activeBuild, hasSession, intentPath, listBuilds, stepPath } from "../lib/sidecar.js";
11
- import { setLocalSetting } from "../lib/settings.js";
10
+ import { activeBuild, hasSession, intentPath, listBuilds, setActiveBuild, stepPath } from "../lib/sidecar.js";
12
11
  export function use(cwd, args) {
13
12
  const root = findRepoRoot(cwd);
14
13
  if (root === null || !hasSession(root)) {
@@ -32,7 +31,7 @@ export function use(cwd, args) {
32
31
  process.stderr.write(`plumbbob: note — build "${leaving}" has a step in flight; its in-flight state is preserved ` +
33
32
  `and resumes when you \`use ${leaving}\` again.\n`);
34
33
  }
35
- setLocalSetting(root, 'activeBuild', target);
34
+ setActiveBuild(root, target);
36
35
  const resuming = existsSync(stepPath(root, target)) ? ' (a step is in flight — `status` shows where)' : '';
37
36
  process.stdout.write(`plumbbob: now on build "${target}"${resuming}. \`status\` to orient.\n`);
38
37
  return 0;
@@ -5,16 +5,16 @@
5
5
  # the tools are absent or there is no session. tsc is deferred to the heavy tier
6
6
  # (D25): it has no true single-file mode and would tax every keystroke.
7
7
 
8
- # The repo root is the nearest ancestor whose .plumbbob/settings.local.json carries
9
- # an `activeBuild` cursor — the per-worktree signal that a tracked build is live
10
- # here (D28). Grep the same way the file_path read below does: a plain pattern
11
- # match, no JSON parse. The cursor replaces the old .plumbbob/STATE probe as the
12
- # "a build is active here" root marker.
8
+ # The repo root is the nearest ancestor whose .plumbbob/STATE is NON-EMPTY — the
9
+ # per-worktree signal that a tracked build is live here (D28). STATE's existence
10
+ # means "a session is active"; its content is the active-build cursor, so a non-empty
11
+ # STATE is exactly "a tracked build is live" (the `--local` layout leaves STATE empty,
12
+ # and this file-scoped check is a tracked-build affordance). `-s` tests both existence
13
+ # and non-emptiness — no JSON parse, no cursor file to read.
13
14
  find_root() {
14
15
  d=$(pwd -P)
15
16
  while [ -n "$d" ]; do
16
- if [ -f "$d/.plumbbob/settings.local.json" ] &&
17
- grep -q '"activeBuild"' "$d/.plumbbob/settings.local.json" 2>/dev/null; then
17
+ if [ -s "$d/.plumbbob/STATE" ]; then
18
18
  printf '%s' "$d"
19
19
  return 0
20
20
  fi
@@ -8,8 +8,8 @@
8
8
 
9
9
  # The repo root is the nearest ancestor with an active session — `.plumbbob/STATE`,
10
10
  # the sentinel `start` writes and `finish` removes. Present in both the tracked and
11
- # the `--local` layout (the activeBuild cursor post-edit.sh probes exists only in
12
- # the tracked one, and this hook must guard `--local` steps too).
11
+ # the `--local` layout (post-edit.sh only fires when STATE is non-empty, i.e. a
12
+ # tracked build; this hook must guard `--local` steps too, so it probes existence).
13
13
  find_root() {
14
14
  d=$(pwd -P)
15
15
  while [ -n "$d" ]; do
@@ -26,12 +26,11 @@ find_root() {
26
26
  root=$(find_root) || exit 0 # no active session here: nothing to guard
27
27
 
28
28
  # The in-flight signal is STEP (cleared when the step checkpoints): inside the
29
- # active build's folder when the cursor names one, else the flat sidecar STEP of
30
- # the `--local` layout. jq if present, else the same sed fallback post-edit.sh
31
- # uses for its field read.
32
- settings="$root/.plumbbob/settings.local.json"
33
- slug=$(jq -r '.activeBuild // empty' "$settings" 2>/dev/null)
34
- [ -z "$slug" ] && slug=$(sed -n 's/.*"activeBuild"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$settings" 2>/dev/null | head -n1)
29
+ # active build's folder when STATE names one, else the flat sidecar STEP of the
30
+ # `--local` layout. STATE's content IS the cursor (D28) — read the first line, trim
31
+ # whitespace, and treat the legacy `active` sentinel (pre-STATE-cursor) as no cursor.
32
+ slug=$(head -n1 "$root/.plumbbob/STATE" 2>/dev/null | tr -d '[:space:]')
33
+ [ "$slug" = "active" ] && slug=""
35
34
  if [ -n "$slug" ]; then
36
35
  step_file="$root/.plumbbob/builds/$slug/STEP"
37
36
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.8.0",
3
+ "version": "0.8.3",
4
4
  "description": "Attention-first build process: 12 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -83,15 +83,24 @@ differs from the model you're running as, say so before implementing — the hum
83
83
  `--body` for the deterministic done-when + seam + diffstat fallback). Do **not** bump
84
84
  the version or changelog — that is the human's `/version` call.
85
85
 
86
- **The latch makes this pause real (D64).** On the default (non-`--auto`) path you
86
+ **The latch makes this pause real.** On the default (non-`--auto`) path you
87
87
  build the step and reach the pause *in one turn* — so under plumbbob's turn hook the
88
88
  checkpoint at the end of that turn is **refused** ("no human turn since this step
89
89
  began"), and that refusal **is** the pause working as designed. Do exactly what the
90
90
  pause already asks: present the diff and the self-review, **end the turn**, and the
91
91
  human's next message is the tick that lands the checkpoint when you re-fire. **Never
92
- route around a refusal with a raw `git commit`** — the work plane stays free (D10/D13),
92
+ route around a refusal with a raw `git commit`** — the work plane stays free,
93
93
  but the *record* is latched on purpose.
94
94
 
95
+ **Then hand off with the next model.** Once the checkpoint lands on re-fire, close the
96
+ turn by citing where the loop now stands: the step you just completed and the next
97
+ undone step. If that next step carries a `- model:` recommendation (the plan's
98
+ smallest-model-that-fits call, echoed on `plumbbob status`'s `▸ next` line), **name
99
+ it** so the human knows which `/model` to select before firing `/pb-build` again — it
100
+ is what carries the plan's suggestion across a fresh context window, which inherits the
101
+ session model, not the plan's. No `- model:` line means any model will do. Guidance,
102
+ never a gate.
103
+
95
104
  ## `--auto` — let the agent be the clock (opt-in)
96
105
 
97
106
  `/pb-build --auto` is the explicit escape hatch when the human wants unattended
@@ -108,7 +117,10 @@ and approves in the human's place**, and it **chains**:
108
117
  the self-review finds a mismatch (surface exactly what, and do not checkpoint it), a
109
118
  bound agent returns `blocked` or `drift` (unblock-and-re-run, or `/pb-refine` — an
110
119
  agent cannot advance the loop), a new decision is needed, no planned steps
111
- remain, or the top of a requested range is reached.
120
+ remain, or the top of a requested range is reached. **When `--auto` halts back to the
121
+ human, give the same hand-off the default pause does** — the step just completed, the
122
+ next undone step, and that next step's `- model:` recommendation if it has one, so a
123
+ fresh context window knows which `/model` to select.
112
124
 
113
125
  `--auto` and a step range are the only paths that checkpoint without a human pause, and
114
126
  only because the human asked for it by name; a range re-imposes the pause at its top.
@@ -150,8 +162,12 @@ just the one more entry already in the halt list above.
150
162
  `build` writes the diff, `after` is advisory — none can checkpoint, flip a step, or
151
163
  chain. `blocked` → unblock and re-run; `drift` → `/pb-refine`. You are still the one
152
164
  who verifies and (bar `--auto`) the human is still the clock.
153
- - **A refused checkpoint is the pause, not an error (D64).** Under plumbbob's turn
165
+ - **A refused checkpoint is the pause, not an error.** Under plumbbob's turn
154
166
  hook a same-turn `checkpoint` is refused by design — present the diff, **end the
155
167
  turn**, and the human's next message is the tick that lands it on re-fire. Never route
156
168
  around it with a raw `git commit`; `--auto`, a typed range, or `auto: true` are the
157
169
  only self-approvals.
170
+ - **Close with the next model.** When a step lands, cite the completed step and the next
171
+ undone step, and name that next step's `- model:` recommendation if it has one — it's
172
+ what a fresh context window needs to pick the right `/model` before re-firing.
173
+ Guidance, never a gate.
@@ -31,7 +31,8 @@ record.
31
31
  2. **Finish.** Run `plumbbob finish`, which appends the checkpoint SHAs to the
32
32
  report and makes the final commit — subject `plumbbob: finish — <title>`,
33
33
  with an optional proportional body via `--body` (the stdin heredoc) — then
34
- clears the control state (markers, the `activeBuild` cursor, STATE last). The
34
+ clears the control state (the in-flight markers, then `STATE` last — one delete
35
+ drops both the session sentinel and the active-build cursor it carries). The
35
36
  build folder stays in place, committed, and rides the branch into the PR.
36
37
  3. **Point at the next goal** — `/pb-plan` to frame the next one.
37
38
 
@@ -107,7 +107,7 @@ an agent can follow with `/pb-build`. The argument only seeds how you get there.
107
107
  baseline → plan → steps. Pass a proportional `--body` (the single-quoted stdin
108
108
  heredoc) when the rationale is worth carrying; skip it for a small plan. Do this
109
109
  only on the human's approval — the plan is their convergence.
110
- - **The plan commit is latched too (D64).** Once the turn ledger exists, `checkpoint
110
+ - **The plan commit is latched too.** Once the turn ledger exists, `checkpoint
111
111
  --plan` refuses to land in the same turn `start` stamped it: present the plan,
112
112
  **end the turn**, and the human's approving message is the tick that lets it
113
113
  commit on re-fire — the refusal *is* the plan pause. Never route around it with a
@@ -62,8 +62,18 @@ this skill verifies it the same way: **it reads the diff, not the author**.
62
62
  write in one breath. Omit `--body` entirely and the CLI writes a deterministic body
63
63
  (done-when + seam + diffstat) on its own. Either way, do **not** bump the version or
64
64
  touch the changelog — that is the human's `/version` call.
65
+ 7. **Hand off with the next model** *(once the checkpoint lands)*. `plumbbob checkpoint`
66
+ prints `step N checkpointed … Back at the boundary` and returns to DESIGN. Close the
67
+ turn by citing where the loop now stands: the step you just completed and the **next
68
+ undone step** — its title and done-when. If that next step carries a `- model:`
69
+ recommendation (the plan's smallest-model-that-fits call, which `plumbbob status`
70
+ echoes on the `▸ next` line), **name it in the hand-off** so the human knows which
71
+ `/model` to select before firing `/pb-build` again. This matters most across a context
72
+ boundary: a fresh window inherits the *session's* model, not the plan's suggestion, so
73
+ this line is what carries the recommendation over. No `- model:` line means any model
74
+ will do — say nothing about it. Guidance, never a gate.
65
75
 
66
- ## The latch makes the pause real (D64)
76
+ ## The latch makes the pause real
67
77
 
68
78
  When this session runs under plumbbob's turn hook, `plumbbob checkpoint` **refuses to
69
79
  land a step in the same turn it was entered** — and that refusal **is** this pause, not
@@ -73,7 +83,7 @@ the self-review, **end the turn**, and the human's next message is the tick that
73
83
  the checkpoint land when you re-fire. **Never reach for a raw `git commit` to force the
74
84
  land** — that forges the very record the latch exists to keep honest, and the
75
85
  commit-ask hook asks the human about it anyway. The refusal is a healthy latch doing its
76
- job on the *record* while the *work* plane stays free (D10/D13); a `/pb-build --auto` or
86
+ job on the *record* while the *work* plane stays free; a `/pb-build --auto` or
77
87
  a typed step range in the human's own prompt is the only self-approval, and `auto: true`
78
88
  in settings is the standing one.
79
89
 
@@ -90,7 +100,11 @@ in settings is the standing one.
90
100
  - **`after`-agents advise; they never gate**. Their output feeds the
91
101
  self-review — checkride gates, the human approves. `blocked` → unblock and re-run;
92
102
  `drift` → `/pb-refine` before checkpointing. No code path makes them blocking.
93
- - **A refused checkpoint is the pause, never a workaround (D64).** Under the turn
103
+ - **A refused checkpoint is the pause, never a workaround.** Under the turn
94
104
  hook a same-turn checkpoint is refused *by design* — present the diff, **end the
95
105
  turn**, and let the human's next message re-tick it. Never route around it with a raw
96
106
  `git commit`; the latch guards the record, not the work.
107
+ - **Close with the next model.** After the checkpoint lands, cite the completed step
108
+ and the next undone step, and surface that next step's `- model:` recommendation when
109
+ it has one — it is the plan's smallest-model-that-fits call, and it's what a fresh
110
+ context window needs to pick the right `/model`. Guidance, never a gate.