plumbbob 0.4.13 → 0.5.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.
@@ -78,11 +78,30 @@ export function parseStepSeam(content, step) {
78
78
  return { ok: true, seam };
79
79
  }
80
80
  // Seam membership (D23): a repo-relative path is in-seam if it equals an exact
81
- // token, or is prefixed by a `dir/` grant. Shared by `done` (scope-drift warn)
82
- // and `revert` (untracked cleanup).
81
+ // token, or is prefixed by a `dir/` grant. Shared by `checkpoint` (scope-drift
82
+ // warn) and `revert` (untracked cleanup).
83
83
  export function matchesSeam(relPath, tokens) {
84
84
  return tokens.some((token) => (token.endsWith('/') ? relPath.startsWith(token) : relPath === token));
85
85
  }
86
+ // Plumbbob's own artifact plane (D17): everything under `.plumbbob/` is plumbbob's
87
+ // bookkeeping — the tracked intent/build-log/checkpoints that ride the branch, and
88
+ // the excluded control markers. It is never the user's code, so it never counts as
89
+ // scope drift, and `revert`'s untracked cleanup must never delete it. checkpoint
90
+ // stages this plane itself on every tick (the `[x]` flip, the build-log line), so
91
+ // without this whitelist every checkpoint would cry wolf about its own writes.
92
+ export function isArtifactPath(relPath) {
93
+ return relPath === '.plumbbob' || relPath.startsWith('.plumbbob/');
94
+ }
95
+ // The staged paths that fall outside the step's seam AND outside the artifact
96
+ // plane — the scope-drift set `checkpoint` warns about (guidance, not a gate: the
97
+ // checkpoint still commits them). An empty seam yields no drift, so callers that
98
+ // cannot resolve a seam simply skip the warning rather than flagging everything.
99
+ export function scopeDrift(paths, seam) {
100
+ if (seam.length === 0) {
101
+ return [];
102
+ }
103
+ return paths.filter((p) => !matchesSeam(p, seam) && !isArtifactPath(p));
104
+ }
86
105
  function fail(error) {
87
106
  return { ok: false, error };
88
107
  }
@@ -110,9 +110,9 @@ function nextMove(spiking, steps, inFlight, parked) {
110
110
  return 'plan the first step — `/plumbbob:pb-step`';
111
111
  }
112
112
  // Batch-default: the steps were planned up front, so finishing them usually
113
- // means "wrap up" — but `/plumbbob:pb-step` can still add an increment if reality grew.
113
+ // means "finish up" — but `/plumbbob:pb-step` can still add an increment if reality grew.
114
114
  const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/plumbbob:pb-harvest\`; then ` : '';
115
- return `${harvest}wrap up — \`/plumbbob:pb-wrap\` (or \`/plumbbob:pb-step\` to add another increment)`;
115
+ return `${harvest}finish up — \`/plumbbob:pb-finish\` (or \`/plumbbob:pb-step\` to add another increment)`;
116
116
  }
117
117
  return nextUndone.planned
118
118
  ? `build step ${nextUndone.n} — \`/plumbbob:pb-build\` (or \`/plumbbob:pb-step\` to revise it first)`
@@ -0,0 +1,71 @@
1
+ // The settings ladder (D27): a resolved setting comes from, in priority order,
2
+ // 1. a CLI flag — passed in by the verb (undefined when absent)
3
+ // 2. settings.local.json — untracked personal overlay + per-worktree cursor
4
+ // 3. settings.json — tracked project defaults
5
+ // 4. a built-in default — supplied by the caller
6
+ // The first defined rung wins. Both files are optional JSON; a missing or
7
+ // malformed file contributes nothing rather than throwing, so a broken personal
8
+ // overlay can never wedge the tool. Functional/procedural, node builtins (C1/C2).
9
+ //
10
+ // Known keys: `check` (string — the heavy gate, tracked in settings.json) and
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.
15
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ const DIRNAME = '.plumbbob';
18
+ export function settingsPath(root) {
19
+ return join(root, DIRNAME, 'settings.json');
20
+ }
21
+ export function localSettingsPath(root) {
22
+ return join(root, DIRNAME, 'settings.local.json');
23
+ }
24
+ function readSettings(path) {
25
+ try {
26
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
27
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ // The raw ladder: flag → local overlay → project defaults → undefined. The typed
34
+ // helpers below apply the built-in default and reject wrong-typed rungs.
35
+ function resolveSetting(root, key, flag) {
36
+ if (flag !== undefined)
37
+ return flag;
38
+ const local = readSettings(localSettingsPath(root))[key];
39
+ if (local !== undefined)
40
+ return local;
41
+ return readSettings(settingsPath(root))[key];
42
+ }
43
+ // Resolve a string setting (e.g. `check`). A missing rung, or one holding a
44
+ // non-string / blank value, yields the caller's fallback rather than gating on
45
+ // garbage.
46
+ export function resolveString(root, key, fallback, flag) {
47
+ const value = resolveSetting(root, key, flag);
48
+ return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
49
+ }
50
+ // Resolve a boolean setting (e.g. `auto`). A missing or non-boolean rung yields
51
+ // the caller's fallback.
52
+ export function resolveBoolean(root, key, fallback, flag) {
53
+ const value = resolveSetting(root, key, flag);
54
+ return typeof value === 'boolean' ? value : fallback;
55
+ }
56
+ // Read one key from the untracked local overlay ONLY — no project or built-in
57
+ // fallback. The `activeBuild` cursor lives here and must never resolve from the
58
+ // tracked settings.json (it is per-worktree state, not a shared default).
59
+ export function localSetting(root, key) {
60
+ return readSettings(localSettingsPath(root))[key];
61
+ }
62
+ // Merge one key into settings.local.json, preserving the other keys and creating
63
+ // the file when absent. Pretty-printed so the overlay stays hand-editable. A
64
+ // malformed existing file contributes nothing (readSettings yields {}) and is
65
+ // overwritten with a clean object rather than throwing.
66
+ export function setLocalSetting(root, key, value) {
67
+ const path = localSettingsPath(root);
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ const merged = { ...readSettings(path), [key]: value };
70
+ writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`);
71
+ }
@@ -6,45 +6,128 @@
6
6
  // and nothing reads its content. The phase the dashboard shows (DESIGN/BUILD/
7
7
  // SPIKE) is derived, not stored — BUILD ⇔ a STEP is in flight, SPIKE ⇔ the SPIKE
8
8
  // marker is present, otherwise DESIGN.
9
- import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
10
- import { join } from 'node:path';
11
- import { gitDir } from "./git.js";
9
+ import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
10
+ import { dirname, join } from 'node:path';
11
+ import { gitPath } from "./git.js";
12
+ import { localSetting } from "./settings.js";
12
13
  const DIRNAME = '.plumbbob';
13
14
  export function sidecarDir(root) {
14
15
  return join(root, DIRNAME);
15
16
  }
17
+ // The tracked artifact plane (D26): each build owns a self-contained folder under
18
+ // `.plumbbob/builds/<slug>/` that rides its branch into the PR. `buildDir` is the
19
+ // per-build root; intent.md, build-log.md, checkpoints, and report.md live inside
20
+ // it (the in-flight STEP/SEAM/SPIKE markers do too, but stay git-excluded).
21
+ function buildsDir(root) {
22
+ return join(root, DIRNAME, 'builds');
23
+ }
24
+ export function buildDir(root, slug) {
25
+ return join(buildsDir(root), slug);
26
+ }
27
+ // Derive a filesystem-safe slug from a build title: lowercased, every run of
28
+ // non-alphanumerics collapsed to a single hyphen, trimmed of leading/trailing
29
+ // hyphens. The CLI stays dumb and explicit (D38) — collision handling belongs to
30
+ // the caller (`start` refuses rather than silently suffixing `-2`). A title with
31
+ // no alphanumerics yields `''`, which the caller must reject or override.
32
+ export function slugify(title) {
33
+ return title
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, '-')
36
+ .replace(/^-+|-+$/g, '');
37
+ }
38
+ // Existing build slugs — the directory names under `builds/`, sorted. Empty when
39
+ // `builds/` is absent (a `--local` repo, or before the first tracked `start`).
40
+ export function listBuilds(root) {
41
+ try {
42
+ return readdirSync(buildsDir(root), { withFileTypes: true })
43
+ .filter((entry) => entry.isDirectory())
44
+ .map((entry) => entry.name)
45
+ .sort();
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ // Resolve which build a verb acts on (D28): an explicit `--build <slug>` flag →
52
+ // the `activeBuild` cursor in settings.local.json → the sole build in `builds/`
53
+ // → null (the caller then refuses with a hint). One-active-per-worktree holds by
54
+ // construction: the cursor is a single scalar key in an untracked file, so it can
55
+ // never point at two builds (D28).
56
+ export function activeBuild(root, flag) {
57
+ if (flag !== undefined && flag.length > 0)
58
+ return flag;
59
+ const cursor = localSetting(root, 'activeBuild');
60
+ if (typeof cursor === 'string' && cursor.length > 0)
61
+ return cursor;
62
+ const builds = listBuilds(root);
63
+ return builds.length === 1 ? (builds[0] ?? null) : null;
64
+ }
65
+ // The build a verb should act on, plus its argv with the `--build <slug>` pair
66
+ // stripped (D28). Every verb resolves through this: an explicit `--build <slug>`
67
+ // wins, else the cursor / sole-build fallback of `activeBuild`. `rest` matters
68
+ // because the slug is a bare token — scanning positionals on the raw argv would let
69
+ // it masquerade as a step number or a spike slug, so callers scan `rest` instead.
70
+ export function resolveBuild(root, args) {
71
+ const i = args.indexOf('--build');
72
+ if (i === -1)
73
+ return { build: activeBuild(root), rest: args };
74
+ return { build: activeBuild(root, args[i + 1]), rest: [...args.slice(0, i), ...args.slice(i + 2)] };
75
+ }
16
76
  function statePath(root) {
17
77
  return join(root, DIRNAME, 'STATE');
18
78
  }
79
+ // Where a build's artifacts and in-flight markers live (D26): the `builds/<slug>/`
80
+ // folder for the resolved build, else the flat sidecar root. `slug` is the value
81
+ // the verb already resolved via `resolveBuild`/`activeBuild`; omit it and the dir
82
+ // resolves from the cursor (the default the executor-agnostic path reads lean on).
83
+ // Either way a `null` slug falls back to the flat sidecar root, which covers the
84
+ // `--local` layout (D26) and any no-cursor/no-build repo, so the path reads stay
85
+ // stable even before a tracked build exists or when a "no active session" guard is
86
+ // about to fire.
87
+ function artifactDir(root, slug) {
88
+ const resolved = slug === undefined ? activeBuild(root) : slug;
89
+ return resolved === null ? sidecarDir(root) : buildDir(root, resolved);
90
+ }
91
+ // The resolved build's artifact folder — the `builds/<slug>/` dir for the active
92
+ // build (or the flat sidecar root under `--local`/no-cursor). Public so the
93
+ // plan-approval commit can stage exactly this build's scaffold and nothing else
94
+ // (D36); `slug` follows the same resolution as the path helpers above.
95
+ export function buildFolder(root, slug) {
96
+ return artifactDir(root, slug);
97
+ }
19
98
  // The SPIKE marker (a single-purpose presence flag, like SEAM/STEP): written by
20
99
  // `spike` on open, removed on `spike done`. Its existence is the one signal that
21
100
  // the dashboard and the spike gates read to know "a spike is active".
22
- export function spikePath(root) {
23
- return join(root, DIRNAME, 'SPIKE');
101
+ export function spikePath(root, slug) {
102
+ return join(artifactDir(root, slug), 'SPIKE');
24
103
  }
25
104
  // SEAM and STEP carry the in-flight step (D4/D7): a plain path list and a bare
26
105
  // number, so the hooks read them with a grep and no markdown parsing.
27
- export function seamPath(root) {
28
- return join(root, DIRNAME, 'SEAM');
106
+ export function seamPath(root, slug) {
107
+ return join(artifactDir(root, slug), 'SEAM');
29
108
  }
30
- export function stepPath(root) {
31
- return join(root, DIRNAME, 'STEP');
109
+ export function stepPath(root, slug) {
110
+ return join(artifactDir(root, slug), 'STEP');
32
111
  }
33
- export function checkpointsPath(root) {
34
- return join(root, DIRNAME, 'checkpoints');
112
+ export function checkpointsPath(root, slug) {
113
+ return join(artifactDir(root, slug), 'checkpoints');
35
114
  }
36
- export function configPath(root) {
37
- return join(root, DIRNAME, 'config');
115
+ export function intentPath(root, slug) {
116
+ return join(artifactDir(root, slug), 'intent.md');
38
117
  }
39
- export function intentPath(root) {
40
- return join(root, DIRNAME, 'intent.md');
118
+ export function buildLogPath(root, slug) {
119
+ return join(artifactDir(root, slug), 'build-log.md');
41
120
  }
42
- export function buildLogPath(root) {
43
- return join(root, DIRNAME, 'build-log.md');
121
+ // report.md sits beside intent.md / build-log.md inside the build folder. The
122
+ // pb-finish skill writes it and `finish` commits it with the folder — the folder
123
+ // IS the archive now (D29), so the report rides the branch into the PR instead of
124
+ // being copied into a local-only `archive/` (which retired with `archive.ts`).
125
+ export function reportPath(root, slug) {
126
+ return join(artifactDir(root, slug), 'report.md');
44
127
  }
45
- // A session exists iff STATE exists. Deleting STATE (at wrap) is what flips the
128
+ // A session exists iff STATE exists. Deleting STATE (at finish) is what flips the
46
129
  // repo back to "no session" — so it is the single source of truth for "is there
47
- // a session". `start` calls beginSession; `wrap` removes the file.
130
+ // a session". `start` calls beginSession; `finish` removes the file.
48
131
  export function hasSession(root) {
49
132
  return existsSync(statePath(root));
50
133
  }
@@ -52,19 +135,22 @@ export function beginSession(root) {
52
135
  writeFileSync(statePath(root), 'active\n');
53
136
  }
54
137
  // SPIKE marker helpers — existence is the whole signal (content is irrelevant).
55
- export function inSpike(root) {
56
- return existsSync(spikePath(root));
138
+ export function inSpike(root, slug) {
139
+ return existsSync(spikePath(root, slug));
57
140
  }
58
- export function markSpike(root) {
59
- writeFileSync(spikePath(root), 'active\n');
141
+ export function markSpike(root, slug) {
142
+ writeFileSync(spikePath(root, slug), 'active\n');
60
143
  }
61
- export function clearSpike(root) {
62
- rmSync(spikePath(root), { force: true });
144
+ export function clearSpike(root, slug) {
145
+ rmSync(spikePath(root, slug), { force: true });
63
146
  }
64
- // D17: keep the sidecar untracked by appending `.plumbbob/` to the repo's
65
- // git/info/exclude. Idempotent — a re-`start` after finish must not double-add.
66
- export function excludeSidecar(root) {
67
- const exclude = join(gitDir(root), 'info', 'exclude');
147
+ // Append `patterns` to the repo's info/exclude, each at most once (idempotent
148
+ // a re-`start` after finish must not double-add). `gitPath` resolves to the
149
+ // *common* gitdir's exclude — the only one git reads — so this works from a
150
+ // linked worktree, whose per-worktree gitdir has no `info/` (D33).
151
+ function addExcludes(root, patterns) {
152
+ const exclude = gitPath(root, 'info/exclude');
153
+ mkdirSync(dirname(exclude), { recursive: true });
68
154
  let current = '';
69
155
  try {
70
156
  current = readFileSync(exclude, 'utf8');
@@ -72,9 +158,33 @@ export function excludeSidecar(root) {
72
158
  catch {
73
159
  current = '';
74
160
  }
75
- if (current.split('\n').some((line) => line.trim() === `${DIRNAME}/`)) {
161
+ const present = new Set(current.split('\n').map((line) => line.trim()));
162
+ const missing = patterns.filter((pattern) => !present.has(pattern));
163
+ if (missing.length === 0)
76
164
  return;
77
- }
78
165
  const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
79
- appendFileSync(exclude, `${prefix}${DIRNAME}/\n`);
166
+ appendFileSync(exclude, `${prefix}${missing.join('\n')}\n`);
167
+ }
168
+ // The narrowed control plane (D17): with `builds/<slug>/` now tracked, only the
169
+ // per-worktree control files stay git-excluded — the local settings overlay (its
170
+ // `activeBuild` cursor), the session sentinel, and the in-flight step markers
171
+ // inside every build. Everything else under `.plumbbob/` (settings.json, and each
172
+ // build's intent/build-log/checkpoints/report) rides the branch into the PR.
173
+ export function excludeControl(root) {
174
+ addExcludes(root, [
175
+ `${DIRNAME}/STATE`,
176
+ `${DIRNAME}/settings.local.json`,
177
+ `${DIRNAME}/builds/*/STEP`,
178
+ `${DIRNAME}/builds/*/SEAM`,
179
+ `${DIRNAME}/builds/*/SPIKE`,
180
+ // The checkride gate (D32) writes raw tool output to `.check/`; checkpoint's
181
+ // stageAll must never sweep it into a step commit.
182
+ '.check/',
183
+ ]);
184
+ }
185
+ // D26: `start --local` opts out of the tracked layout into a fully-untracked
186
+ // sidecar (today's behavior) — some team repos won't accept tool folders in-tree.
187
+ // Excludes the whole `.plumbbob/` directory.
188
+ export function excludeSidecar(root) {
189
+ addExcludes(root, [`${DIRNAME}/`, '.check/']);
80
190
  }
@@ -3,7 +3,7 @@
3
3
  // BUILD phase from it); it never checkpoints (only `checkpoint` commits).
4
4
  import { readFileSync, writeFileSync } from 'node:fs';
5
5
  import { findRepoRoot } from "../lib/git.js";
6
- import { hasSession, intentPath, seamPath, stepPath } from "../lib/sidecar.js";
6
+ import { hasSession, intentPath, resolveBuild, seamPath, stepPath } from "../lib/sidecar.js";
7
7
  import { parseStepSeam } from "../lib/intent.js";
8
8
  export function build(cwd, args) {
9
9
  const root = findRepoRoot(cwd);
@@ -11,19 +11,20 @@ export function build(cwd, args) {
11
11
  process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
12
12
  return 1;
13
13
  }
14
- const raw = args.find((a) => !a.startsWith('--'));
14
+ const { build: slug, rest } = resolveBuild(root, args);
15
+ const raw = rest.find((a) => !a.startsWith('--'));
15
16
  if (raw === undefined || !/^\d+$/.test(raw) || Number(raw) < 1) {
16
17
  process.stderr.write('plumbbob: build needs a step number. Try: plumbbob build 2.\n');
17
18
  return 1;
18
19
  }
19
20
  const step = Number(raw);
20
- const parsed = parseStepSeam(readFileSync(intentPath(root), 'utf8'), step);
21
+ const parsed = parseStepSeam(readFileSync(intentPath(root, slug), 'utf8'), step);
21
22
  if (!parsed.ok) {
22
23
  process.stderr.write(`plumbbob: ${parsed.error} Fix the step's seam in intent.md, then \`build ${step}\` again.\n`);
23
24
  return 1;
24
25
  }
25
- writeFileSync(seamPath(root), `${parsed.seam.join('\n')}\n`);
26
- writeFileSync(stepPath(root), `${step}\n`);
27
- process.stdout.write(`plumbbob: building step ${step}. Seam (for orientation; not a lock in v2):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
26
+ writeFileSync(seamPath(root, slug), `${parsed.seam.join('\n')}\n`);
27
+ writeFileSync(stepPath(root, slug), `${step}\n`);
28
+ process.stdout.write(`plumbbob: building step ${step}. Seam (for orientation; not a lock):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
28
29
  return 0;
29
30
  }
@@ -1,17 +1,51 @@
1
- // `plumbbob check` — run the heavy gate (D16/D24) and report, with NO state
1
+ // `plumbbob check` — run the heavy gate (D16/D24/D32) and report, with NO state
2
2
  // change. The read-only half of the verify tick: `/plumbbob:pb-verify` runs this before the
3
3
  // pause so the human approves on a known-green check. Exits with the check's own
4
- // code (0 = green).
4
+ // code (0 = green, 1 = red, 2 = the gate itself broke).
5
+ //
6
+ // Narrowing flags for the iteration loop (`check --bail --only types,lint`) map
7
+ // straight onto checkride's RunFlags (D32). Only this verb takes them — the
8
+ // checkpoint gate is always full-fat.
5
9
  import { findRepoRoot } from "../lib/git.js";
6
10
  import { hasSession } from "../lib/sidecar.js";
7
11
  import { runCheck } from "../lib/check.js";
8
- export function check(cwd) {
12
+ export async function check(cwd, args = []) {
9
13
  const root = findRepoRoot(cwd);
10
14
  if (root === null || !hasSession(root)) {
11
15
  process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
12
16
  return 1;
13
17
  }
14
- const code = runCheck(root);
15
- process.stdout.write(code === 0 ? '\nplumbbob: check green.\n' : '\nplumbbob: check RED — fix it before checkpointing.\n');
18
+ const code = await runCheck(root, parseFlags(args));
19
+ process.stdout.write(verdictLine(code));
16
20
  return code;
17
21
  }
22
+ // argv → CheckFlags: bare booleans plus comma-separated slot lists. Unknown
23
+ // args are ignored rather than refused — the gate itself is the point.
24
+ function parseFlags(args) {
25
+ return {
26
+ ...(args.includes('--bail') ? { bail: true } : {}),
27
+ ...(args.includes('--changed') ? { changed: true } : {}),
28
+ ...(args.includes('--all') ? { all: true } : {}),
29
+ ...slotList(args, '--only'),
30
+ ...slotList(args, '--skip'),
31
+ ...slotList(args, '--include'),
32
+ };
33
+ }
34
+ function slotList(args, flag) {
35
+ const i = args.indexOf(flag);
36
+ const value = i >= 0 ? args[i + 1] : undefined;
37
+ if (value === undefined || value.startsWith('--'))
38
+ return {};
39
+ const names = value
40
+ .split(',')
41
+ .map((s) => s.trim())
42
+ .filter((s) => s.length > 0);
43
+ return names.length > 0 ? { [flag.slice(2)]: names } : {};
44
+ }
45
+ function verdictLine(code) {
46
+ if (code === 0)
47
+ return '\nplumbbob: check green.\n';
48
+ if (code === 2)
49
+ return '\nplumbbob: check ERROR — the gate itself broke; fix the harness before trusting green or red.\n';
50
+ return '\nplumbbob: check RED — fix it before checkpointing.\n';
51
+ }
@@ -1,5 +1,5 @@
1
1
  // `plumbbob checkpoint [<n>] [-m <msg>]` — the executor-agnostic commit tick (D3).
2
- // Unlike v1 `done`, it does NOT require a STEP file: the step is whatever you pass,
2
+ // It does NOT require a STEP file: the step is whatever you pass,
3
3
  // else the in-flight STEP, else the next undone step in intent. It gates on a green
4
4
  // check, then commits any pending work (or records the existing HEAD when the tree
5
5
  // is already clean — the human's commit skill may have committed first), records the
@@ -8,30 +8,39 @@
8
8
  // `/plumbbob:pb-build`, your hands, a vibe session, or another harness all checkpoint
9
9
  // the same way.
10
10
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
- import { commit, findRepoRoot, headSha, isDirty, stageAll } from "../lib/git.js";
12
- import { buildLogPath, checkpointsPath, hasSession, intentPath, seamPath, stepPath } from "../lib/sidecar.js";
11
+ import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
12
+ import { buildFolder, buildLogPath, checkpointsPath, hasSession, intentPath, seamPath, stepPath } from "../lib/sidecar.js";
13
13
  import { runCheck } from "../lib/check.js";
14
- import { markStepDone, parseSteps } from "../lib/orient.js";
14
+ import { markStepDone, parseSteps, parseTitle } from "../lib/orient.js";
15
+ import { parseStepSeam, scopeDrift } from "../lib/intent.js";
15
16
  import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
16
- export function checkpoint(cwd, args) {
17
+ export async function checkpoint(cwd, args) {
17
18
  const root = findRepoRoot(cwd);
18
19
  if (root === null || !hasSession(root)) {
19
20
  process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
20
21
  return 1;
21
22
  }
23
+ if (args.includes('--plan')) {
24
+ return checkpointPlan(root, args);
25
+ }
22
26
  const step = resolveStep(root, args);
23
27
  if (step === null) {
24
28
  process.stderr.write('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
25
29
  return 1;
26
30
  }
27
- if (runCheck(root) !== 0) {
28
- process.stderr.write('plumbbob: check failed (red) — checkpoint refuses on red. Fix it and re-run.\n');
31
+ const gate = await runCheck(root);
32
+ if (gate !== 0) {
33
+ process.stderr.write(gate === 2
34
+ ? 'plumbbob: the check gate itself broke — checkpoint refuses until the harness is fixed.\n'
35
+ : 'plumbbob: check failed (red) — checkpoint refuses on red. Fix it and re-run.\n');
29
36
  return 1;
30
37
  }
31
38
  let sha;
32
39
  if (isDirty(root)) {
33
40
  stageAll(root);
34
- sha = commit(root, messageArg(args) ?? `plumbbob: step ${step} done`);
41
+ warnScopeDrift(root, step);
42
+ const body = bodyArg(args) ?? fallbackBody(root, step);
43
+ sha = commit(root, messageArg(args) ?? subjectForStep(root, step), body);
35
44
  }
36
45
  else {
37
46
  sha = headSha(root);
@@ -44,6 +53,32 @@ export function checkpoint(cwd, args) {
44
53
  process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
45
54
  return 0;
46
55
  }
56
+ // The plan-approval commit (D36): stage only the active build's artifact folder and
57
+ // commit it as `plumbbob: plan — <title>`, then record a `plan <sha>` line. Giving
58
+ // the plan its own commit keeps the first step's diff from absorbing the scaffold, so
59
+ // `git log` reads baseline → plan → steps. No check gate (there is no code work to
60
+ // verify yet), no intent flip, no step markers — the plan lives entirely in DESIGN.
61
+ // An optional `--body` (stdin heredoc, D34) rides along; the folder is whitelisted
62
+ // artifact plane, so there is no scope-drift to warn about.
63
+ function checkpointPlan(root, args) {
64
+ stagePath(root, buildFolder(root));
65
+ const sha = commit(root, planSubject(root), bodyArg(args) ?? undefined);
66
+ appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
67
+ process.stdout.write(`plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
68
+ return 0;
69
+ }
70
+ // The plan commit's CLI-owned subject: the intent's `# <title>`, else a bare
71
+ // `plumbbob: plan` when the title can't be read (mirrors `subjectForStep`'s fallback).
72
+ function planSubject(root) {
73
+ let title = null;
74
+ try {
75
+ title = parseTitle(readFileSync(intentPath(root), 'utf8'));
76
+ }
77
+ catch {
78
+ title = null;
79
+ }
80
+ return title ? `plumbbob: plan — ${title}` : 'plumbbob: plan';
81
+ }
47
82
  // Step resolution (D3): explicit arg > in-flight STEP file > first undone step in
48
83
  // intent.md. Returns null when none can be determined.
49
84
  function resolveStep(root, args) {
@@ -62,6 +97,38 @@ function resolveStep(root, args) {
62
97
  return null;
63
98
  }
64
99
  }
100
+ // Guidance, not a gate (the enforce→guide pivot): warn when the staged tree reaches
101
+ // beyond the step's seam, then commit anyway. The seam comes from the in-flight SEAM
102
+ // file when a build is live, else the step's declared seam in intent.md. Plumbbob's
103
+ // own artifact plane is whitelisted (`scopeDrift`), so the `[x]` flip and build-log
104
+ // line this very checkpoint stages never read as drift. No seam ⇒ no warning.
105
+ function warnScopeDrift(root, step) {
106
+ const seam = seamTokens(root, step);
107
+ const outside = scopeDrift(stagedPaths(root), seam);
108
+ if (outside.length > 0) {
109
+ process.stderr.write(`plumbbob: heads-up — staged paths outside step ${step}'s seam: ${outside.join(', ')}. ` +
110
+ `The checkpoint captures them; if that's real scope drift, the plan may need a \`/plumbbob:pb-step\` revision.\n`);
111
+ }
112
+ }
113
+ // The seam tokens for the in-flight step: the normalized SEAM file `build` wrote
114
+ // (authoritative while a build is live), falling back to the step's declared seam
115
+ // parsed from intent.md. Empty when neither resolves — the caller then skips the
116
+ // warning rather than flagging the whole tree.
117
+ function seamTokens(root, step) {
118
+ try {
119
+ const fromFile = readFileSync(seamPath(root), 'utf8')
120
+ .split('\n')
121
+ .map((l) => l.trim())
122
+ .filter((l) => l.length > 0);
123
+ if (fromFile.length > 0) {
124
+ return fromFile;
125
+ }
126
+ }
127
+ catch {
128
+ // no SEAM file — fall through to the declared seam.
129
+ }
130
+ return seamForStep(root, step);
131
+ }
65
132
  function flipIntent(root, step) {
66
133
  try {
67
134
  writeFileSync(intentPath(root), markStepDone(readFileSync(intentPath(root), 'utf8'), step));
@@ -71,7 +138,7 @@ function flipIntent(root, step) {
71
138
  }
72
139
  }
73
140
  // Append a dated line to the build-log's `## Log` so the build's history accrues at
74
- // each checkpoint instead of being reconstructed at wrap. The step's title is lifted
141
+ // each checkpoint instead of being reconstructed at finish. The step's title is lifted
75
142
  // from intent.md when still present. Best-effort: a missing/odd build-log never blocks
76
143
  // a checkpoint — the `checkpoints` SHA is the source of truth.
77
144
  function logCheckpoint(root, step, sha) {
@@ -88,6 +155,13 @@ function logCheckpoint(root, step, sha) {
88
155
  // best-effort ledger; never fail a checkpoint over the build-log.
89
156
  }
90
157
  }
158
+ // The CLI-owned, deterministic commit subject: the step's title when intent.md still
159
+ // carries one, else the bare `plumbbob: step N done` fallback (D34 — the CLI owns the
160
+ // subject; a `-m` override or `--body` prose is a separate concern).
161
+ function subjectForStep(root, step) {
162
+ const title = titleForStep(root, step);
163
+ return title ? `plumbbob: step ${step} — ${title}` : `plumbbob: step ${step} done`;
164
+ }
91
165
  function titleForStep(root, step) {
92
166
  try {
93
167
  return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => s.n === step)?.title ?? null;
@@ -100,6 +174,68 @@ function messageArg(args) {
100
174
  const i = args.indexOf('-m');
101
175
  return i !== -1 && i + 1 < args.length ? (args[i + 1] ?? null) : null;
102
176
  }
177
+ // `--body` reads the commit body from stdin (the single-quoted heredoc of D34),
178
+ // so the skill can compose proportional prose the CLI never could. Returns null
179
+ // when the flag is absent or stdin is empty — either way the deterministic
180
+ // fallback body takes over. Reading fd 0 blocks until EOF, which the heredoc
181
+ // supplies; a read error (no stdin attached) degrades to the fallback.
182
+ function bodyArg(args) {
183
+ if (!args.includes('--body')) {
184
+ return null;
185
+ }
186
+ try {
187
+ const raw = readFileSync(0, 'utf8').trimEnd();
188
+ return raw.length > 0 ? raw : null;
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ }
194
+ // The deterministic checkpoint body (D35): the step's done-when, its seam, and the
195
+ // staged diffstat — so a hand-built or vibed checkpoint still gets informative
196
+ // history without a model turn. Each part is best-effort; a missing piece is
197
+ // simply omitted, and an empty result leaves the commit body blank.
198
+ function fallbackBody(root, step) {
199
+ const parts = [];
200
+ const doneWhen = doneWhenForStep(root, step);
201
+ if (doneWhen !== null) {
202
+ parts.push(`done when: ${doneWhen}`);
203
+ }
204
+ const seam = seamForStep(root, step);
205
+ if (seam.length > 0) {
206
+ parts.push(`seam: ${seam.join(', ')}`);
207
+ }
208
+ const stat = safeStagedStat(root);
209
+ if (stat.length > 0) {
210
+ parts.push(stat);
211
+ }
212
+ return parts.length > 0 ? parts.join('\n\n') : undefined;
213
+ }
214
+ function doneWhenForStep(root, step) {
215
+ try {
216
+ return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => s.n === step)?.doneWhen ?? null;
217
+ }
218
+ catch {
219
+ return null;
220
+ }
221
+ }
222
+ function seamForStep(root, step) {
223
+ try {
224
+ const parsed = parseStepSeam(readFileSync(intentPath(root), 'utf8'), step);
225
+ return parsed.ok ? parsed.seam : [];
226
+ }
227
+ catch {
228
+ return [];
229
+ }
230
+ }
231
+ function safeStagedStat(root) {
232
+ try {
233
+ return stagedStat(root);
234
+ }
235
+ catch {
236
+ return '';
237
+ }
238
+ }
103
239
  function readStep(root) {
104
240
  try {
105
241
  const raw = readFileSync(stepPath(root), 'utf8').trim();