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.
@@ -1,16 +1,36 @@
1
- // `plumbbob doctor` — diagnose the global plugin link end to end. Read-only: it
2
- // inspects, it never writes. After `plumbbob init`, plumbbob lives as a symlink at
3
- // ~/.claude/skills/plumbbob pointing at the installed package; Claude Code loads it
4
- // in place as a plugin (skills `/plumbbob:*`, the post-edit hook from hooks.json).
5
- // doctor verifies the link resolves to a package carrying the manifest, the skills,
6
- // and the hook — and names the fix for anything missing. The failure class it
7
- // exists for is SILENT (a `/plumbbob:pb-status` that opens an empty dashboard because the
8
- // plugin never linked). Functional, node builtins only (C1/C2).
9
- import { existsSync, lstatSync, readdirSync, readlinkSync } from 'node:fs';
1
+ // `plumbbob doctor` — two diagnostics under one verb.
2
+ //
3
+ // 1. The global plugin link (read-only). After `plumbbob init`, plumbbob lives as a
4
+ // symlink at ~/.claude/skills/plumbbob pointing at the installed package; Claude
5
+ // Code loads it in place as a plugin (skills `/plumbbob:*`, the post-edit hook from
6
+ // hooks.json). doctor verifies the link resolves to a package carrying the manifest,
7
+ // the skills, and the hook and names the fix for anything missing. The failure
8
+ // class it exists for is SILENT (a `/plumbbob:pb-status` that opens an empty dashboard
9
+ // because the plugin never linked).
10
+ //
11
+ // 2. The repo sidecar layout. A repo scaffolded by a pre-restructure plumbbob carries a
12
+ // legacy FLAT sidecar (`.plumbbob/intent.md`, `config`, `archive/`) fully git-excluded.
13
+ // doctor detects it and, with `--migrate`, moves it into the tracked `builds/<slug>/`
14
+ // layout (D31): archive entries and the active session become build folders, `config`
15
+ // becomes `settings.json`, and the whole move is STAGED but never committed — the human
16
+ // owns that commit (Q8). The move is the one that turns a build's record from local-only
17
+ // (dies with `git worktree remove`) into a tracked folder that rides the branch into
18
+ // the PR (supersedes D20).
19
+ //
20
+ // 3. The check gate (D32). When a `check` setting overrides checkride, doctor names the
21
+ // command; otherwise it runs checkride's own doctor and prints the slot/adapter table —
22
+ // "detected but tool missing" is the footgun this exists for.
23
+ //
24
+ // Functional, node builtins plus checkride (C1/C2).
25
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
10
26
  import { homedir } from 'node:os';
11
- import { join } from 'node:path';
27
+ import { dirname, join } from 'node:path';
12
28
  import { fileURLToPath } from 'node:url';
29
+ import { runDoctor } from 'checkride';
13
30
  import { marketplacePlumbbob } from "../lib/plugins.js";
31
+ import { findRepoRoot, gitPath, stagePath } from "../lib/git.js";
32
+ import { buildDir, excludeControl, listBuilds, sidecarDir, slugify } from "../lib/sidecar.js";
33
+ import { resolveString, settingsPath, setLocalSetting } from "../lib/settings.js";
14
34
  // The plumbbob package's own skills/ dir (the canonical set), off this module's
15
35
  // URL so it resolves the same from src/ (dev) and dist/ (published).
16
36
  function packageDir(name) {
@@ -54,34 +74,254 @@ function buildChecks(link, pkg, shipped) {
54
74
  : { ok: false, label: 'hook missing (hooks/hooks.json)', fix: 're-link: plumbbob init' },
55
75
  ];
56
76
  }
57
- export function doctor() {
77
+ // The plugin-link diagnostic: the checks to print plus how many failed.
78
+ function pluginChecks() {
58
79
  const home = process.env.HOME ?? homedir();
59
80
  const link = join(home, '.claude', 'skills', 'plumbbob');
60
81
  const shipped = listSkills(packageDir('skills'));
61
82
  const pkg = linkedPackage(link);
62
83
  const market = marketplacePlumbbob(home);
63
- let checks;
64
84
  if (pkg === null) {
65
- checks =
66
- market.length > 0
67
- ? [{ ok: true, label: `installed via marketplace (${market.join(', ')}) skills load as /plumbbob:*, the CLI is on PATH from the plugin bin/. No init needed.` }]
68
- : [{ ok: false, label: `not linked — no plugin at ${link}`, fix: 'install the marketplace plugin (/plugin install plumbbob@<marketplace>) or run: plumbbob init' }];
69
- }
70
- else {
71
- checks = buildChecks(link, pkg, shipped);
72
- if (market.length > 0) {
73
- checks.unshift({
74
- ok: false,
75
- label: `collision — also installed via marketplace (${market.join(', ')}); two plugins named plumbbob fight over /plumbbob:* and skills can drop to flat names`,
76
- fix: 'keep one — `plumbbob init --uninstall` to use the marketplace plugin, or `/plugin uninstall` the marketplace one to keep this link',
77
- });
85
+ return market.length > 0
86
+ ? [{ ok: true, label: `installed via marketplace (${market.join(', ')}) — skills load as /plumbbob:*, the CLI is on PATH from the plugin bin/. No init needed.` }]
87
+ : [{ ok: false, label: `not linked no plugin at ${link}`, fix: 'install the marketplace plugin (/plugin install plumbbob@<marketplace>) or run: plumbbob init' }];
88
+ }
89
+ const checks = buildChecks(link, pkg, shipped);
90
+ if (market.length > 0) {
91
+ checks.unshift({
92
+ ok: false,
93
+ label: `collision — also installed via marketplace (${market.join(', ')}); two plugins named plumbbob fight over /plumbbob:* and skills can drop to flat names`,
94
+ fix: 'keep one — `plumbbob init --uninstall` to use the marketplace plugin, or `/plugin uninstall` the marketplace one to keep this link',
95
+ });
96
+ }
97
+ return checks;
98
+ }
99
+ // Read a directory's immediate sub-directory names, sorted; [] when it is absent.
100
+ function subdirs(dir) {
101
+ try {
102
+ return readdirSync(dir, { withFileTypes: true })
103
+ .filter((e) => e.isDirectory())
104
+ .map((e) => e.name)
105
+ .sort();
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ }
111
+ // Detect a legacy flat sidecar, or null when the repo is already on the new layout
112
+ // (or has no sidecar). `config` and `archive/` are unambiguous pre-restructure markers
113
+ // — the new layout has neither. A flat `intent.md` alone would also match today's
114
+ // `--local` layout, so it only counts as legacy when the repo is NOT already migrated
115
+ // (no `builds/`, no `settings.json`): that guard is what keeps `--local` untouched.
116
+ export function inspectLegacy(root) {
117
+ const dir = sidecarDir(root);
118
+ if (!existsSync(dir))
119
+ return null;
120
+ const config = existsSync(join(dir, 'config'));
121
+ const archive = subdirs(join(dir, 'archive'));
122
+ const migrated = existsSync(join(dir, 'builds')) || existsSync(settingsPath(root));
123
+ const session = existsSync(join(dir, 'intent.md')) && !migrated;
124
+ if (!config && archive.length === 0 && !session)
125
+ return null;
126
+ return { config, archive, session };
127
+ }
128
+ // The first `# Heading` in a flat intent.md — the build title the slug derives from.
129
+ function titleFromIntent(path) {
130
+ try {
131
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
132
+ const m = line.match(/^#\s+(.+?)\s*$/);
133
+ if (m)
134
+ return m[1] ?? '';
78
135
  }
79
136
  }
137
+ catch {
138
+ /* fall through to the empty default */
139
+ }
140
+ return '';
141
+ }
142
+ // The `check=<cmd>` line from a legacy `.plumbbob/config`, or null when absent.
143
+ function configCheck(path) {
144
+ try {
145
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
146
+ const m = line.match(/^\s*check\s*=\s*(.+?)\s*$/);
147
+ if (m && (m[1] ?? '').length > 0)
148
+ return m[1] ?? null;
149
+ }
150
+ }
151
+ catch {
152
+ /* fall through to null */
153
+ }
154
+ return null;
155
+ }
156
+ // A slug not already claimed by `taken`, suffixing `-2`, `-3`, … only on collision.
157
+ // `start` refuses on collision (D38), but migration is mechanically moving folders that
158
+ // already exist, so it disambiguates rather than aborting mid-move.
159
+ function uniqueSlug(base, taken) {
160
+ const slug = base.length > 0 ? base : 'migrated-build';
161
+ if (!taken.has(slug))
162
+ return slug;
163
+ let n = 2;
164
+ while (taken.has(`${slug}-${n}`))
165
+ n += 1;
166
+ return `${slug}-${n}`;
167
+ }
168
+ // Move whichever of `names` exist from `from/` into `to/`.
169
+ function moveInto(from, to, names) {
170
+ mkdirSync(to, { recursive: true });
171
+ for (const name of names) {
172
+ const src = join(from, name);
173
+ if (existsSync(src))
174
+ renameSync(src, join(to, name));
175
+ }
176
+ }
177
+ // Drop the blanket `.plumbbob/` line the legacy layout wrote to info/exclude, then add
178
+ // the narrowed control-plane patterns (D17) — so the moved `builds/` and `settings.json`
179
+ // become trackable while the per-worktree control files stay excluded.
180
+ function narrowExcludes(root) {
181
+ const exclude = gitPath(root, 'info/exclude');
182
+ try {
183
+ const kept = readFileSync(exclude, 'utf8')
184
+ .split('\n')
185
+ .filter((line) => {
186
+ const t = line.trim();
187
+ return t !== '.plumbbob/' && t !== '.plumbbob';
188
+ });
189
+ writeFileSync(exclude, kept.join('\n'));
190
+ }
191
+ catch {
192
+ /* no exclude yet — excludeControl creates it */
193
+ }
194
+ excludeControl(root);
195
+ }
196
+ // Perform the migration and return a human-readable list of what moved. STAGES the
197
+ // result but never commits (Q8). Returns the actions so doctor can print them; call
198
+ // only when `inspectLegacy` reported a legacy layout.
199
+ export function migrateSidecar(root) {
200
+ const dir = sidecarDir(root);
201
+ const actions = [];
202
+ const taken = new Set(listBuilds(root));
203
+ // config → settings.json (only when the new file is not already present).
204
+ const configPath = join(dir, 'config');
205
+ if (existsSync(configPath)) {
206
+ if (!existsSync(settingsPath(root))) {
207
+ const check = configCheck(configPath);
208
+ const settings = check === null ? { auto: false } : { check, auto: false };
209
+ writeFileSync(settingsPath(root), `${JSON.stringify(settings, null, 2)}\n`);
210
+ actions.push(check === null ? 'config → settings.json' : `config → settings.json (check: ${check})`);
211
+ }
212
+ rmSync(configPath, { force: true });
213
+ }
214
+ // The flat active session → its own build folder, and the cursor points at it: it is
215
+ // the one in-flight build (D28). Migrate it first so it keeps the slug from its title.
216
+ const flatIntent = join(dir, 'intent.md');
217
+ if (existsSync(flatIntent)) {
218
+ const slug = uniqueSlug(slugify(titleFromIntent(flatIntent)), taken);
219
+ taken.add(slug);
220
+ moveInto(dir, buildDir(root, slug), ['intent.md', 'build-log.md', 'checkpoints', 'STEP', 'SEAM', 'SPIKE']);
221
+ setLocalSetting(root, 'activeBuild', slug);
222
+ actions.push(`active session → builds/${slug} (the cursor)`);
223
+ }
224
+ // archive/<slug> → builds/<slug>. These are "done" by simply not being the cursor.
225
+ const archiveDir = join(dir, 'archive');
226
+ for (const name of subdirs(archiveDir)) {
227
+ const slug = uniqueSlug(slugify(name) || name, taken);
228
+ taken.add(slug);
229
+ const target = buildDir(root, slug);
230
+ mkdirSync(dirname(target), { recursive: true });
231
+ renameSync(join(archiveDir, name), target);
232
+ actions.push(`archive/${name} → builds/${slug}`);
233
+ }
234
+ rmSync(archiveDir, { recursive: true, force: true });
235
+ narrowExcludes(root);
236
+ stagePath(root, '.plumbbob');
237
+ actions.push('staged the move (builds/ + settings.json) — commit it yourself');
238
+ return actions;
239
+ }
240
+ // The sidecar section: legacy detection + the offer, or the migration report under
241
+ // `--migrate`. Returns the lines to print and how many problems it found (an
242
+ // un-migrated legacy layout counts as one, so the exit code flags it).
243
+ function sidecarReport(cwd, migrate) {
244
+ const root = findRepoRoot(cwd);
245
+ const legacy = root === null ? null : inspectLegacy(root);
246
+ if (root === null || legacy === null)
247
+ return { lines: [], failed: 0 };
248
+ const lines = ['', 'plumbbob doctor — sidecar layout'];
249
+ if (migrate) {
250
+ for (const action of migrateSidecar(root))
251
+ lines.push(` ✓ ${action}`);
252
+ lines.push(' migrated. Review with `git status`, then commit the staged move yourself.');
253
+ return { lines, failed: 0 };
254
+ }
255
+ const parts = [];
256
+ if (legacy.session)
257
+ parts.push('an active session');
258
+ if (legacy.archive.length > 0)
259
+ parts.push(`${legacy.archive.length} archived build(s)`);
260
+ if (legacy.config)
261
+ parts.push('a config file');
262
+ lines.push(` ✗ legacy flat sidecar detected at .plumbbob/ (${parts.join(', ')}) — the pre-builds/ layout`);
263
+ lines.push(' → move it into the tracked builds/ layout: plumbbob doctor --migrate');
264
+ lines.push(' (archive/ + the active session → builds/, config → settings.json; staged, never committed)');
265
+ return { lines, failed: 1 };
266
+ }
267
+ // The check-gate section (D32): a configured `check` setting names the spawn
268
+ // override and asks nothing more; otherwise checkride's own doctor reports the
269
+ // slot/adapter table. Only rows checkride marks `required` count as problems —
270
+ // an empty slot ("no tool detected") is informational, not a failure, because
271
+ // the runtime gate already refuses a vacuous run.
272
+ async function gateReport(cwd) {
273
+ const root = findRepoRoot(cwd);
274
+ if (root === null)
275
+ return { lines: [], failed: 0 };
276
+ const lines = ['', 'plumbbob doctor — check gate (D32)'];
277
+ const command = resolveString(root, 'check', '');
278
+ if (command.length > 0) {
279
+ lines.push(` ✓ gate: '${command}' — the "check" setting overrides checkride`);
280
+ return { lines, failed: 0 };
281
+ }
282
+ try {
283
+ const silent = { write: () => true };
284
+ const { report } = await runDoctor({ cwd: root, stdout: silent });
285
+ let failed = 0;
286
+ for (const c of report.checks) {
287
+ if (c.category === 'tool') {
288
+ lines.push(toolRow(c));
289
+ }
290
+ else if (c.required && c.status !== 'ok') {
291
+ lines.push(` ✗ ${c.name}${c.hint === null ? '' : `\n → ${c.hint}`}`);
292
+ }
293
+ if (c.required && c.status !== 'ok')
294
+ failed += 1;
295
+ }
296
+ return { lines, failed };
297
+ }
298
+ catch (err) {
299
+ const message = err instanceof Error ? err.message : String(err);
300
+ return { lines: [...lines, ` ✗ checkride doctor failed — ${message}`], failed: 1 };
301
+ }
302
+ }
303
+ // One slot line: `✓ types ← tsc (6.0.3)`, `✗ dead ← fallow` with its install
304
+ // hint, or `○ spell — no tool detected` for an empty (skipping) slot.
305
+ function toolRow(c) {
306
+ if (c.adapter === null || c.adapter === undefined) {
307
+ return ` ○ ${c.slot ?? c.name} — ${c.hint ?? 'no tool detected (slot skips)'}`;
308
+ }
309
+ const mark = c.status === 'ok' ? '✓' : '✗';
310
+ const version = c.found === null ? '' : ` (${c.found})`;
311
+ const hint = c.status !== 'ok' && c.hint !== null ? `\n → ${c.hint}` : '';
312
+ return ` ${mark} ${c.slot ?? c.name} ← ${c.adapter}${version}${hint}`;
313
+ }
314
+ export async function doctor(cwd, args = []) {
315
+ const checks = pluginChecks();
80
316
  const out = ['plumbbob doctor — plugin install'];
81
317
  for (const c of checks) {
82
318
  out.push(c.ok ? ` ✓ ${c.label}` : ` ✗ ${c.label}\n → ${c.fix}`);
83
319
  }
84
- const failed = checks.filter((c) => !c.ok).length;
320
+ const sidecar = sidecarReport(cwd, args.includes('--migrate'));
321
+ out.push(...sidecar.lines);
322
+ const gate = await gateReport(cwd);
323
+ out.push(...gate.lines);
324
+ const failed = checks.filter((c) => !c.ok).length + sidecar.failed + gate.failed;
85
325
  out.push('');
86
326
  out.push(failed === 0
87
327
  ? 'plumbbob: all checks passed. If a skill still misbehaves, restart Claude Code (or /reload-plugins).'
@@ -0,0 +1,101 @@
1
+ // `plumbbob finish` (D9/D34) — the close-out: append the checkpoint SHAs to the
2
+ // report, make the final commit, and clear the control state. The build folder is
3
+ // NOT deleted — it IS the archive now (D29): tracked, it merges with the branch and
4
+ // shows up in the PR, so nothing is copied into a local-only `archive/` (that
5
+ // helper retired with this step). No refuse-without-report gate — guidance offers
6
+ // the artifact, it does not wall the exit (D9). Git footprint stays additive (C5):
7
+ // one forward commit under the greppable `plumbbob: finish — <title>` subject.
8
+ import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
11
+ import { checkpointsPath, hasSession, intentPath, reportPath, resolveBuild, seamPath, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
12
+ import { setLocalSetting } from "../lib/settings.js";
13
+ import { parseTitle } from "../lib/orient.js";
14
+ export function finish(cwd, args = []) {
15
+ const root = findRepoRoot(cwd);
16
+ if (root === null || !hasSession(root)) {
17
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
18
+ return 1;
19
+ }
20
+ const { build: slug } = resolveBuild(root, args);
21
+ if (existsSync(reportPath(root, slug))) {
22
+ appendCheckpointShas(root, slug);
23
+ }
24
+ else {
25
+ process.stderr.write('plumbbob: note — no report.md found; finishing without one ' +
26
+ '(/plumbbob:pb-finish normally writes the report first). No gate (D9).\n');
27
+ }
28
+ // The final commit (D34): stage the report just written plus the build folder's
29
+ // tail (the last step's checkpoint line lands one commit late, D37) and commit it
30
+ // under the greppable `finish` subject. `--allow-empty` (via `commit`) still marks
31
+ // the narrative endpoint when the tree is already clean, or under `--local`, where
32
+ // the whole sidecar is excluded and there is nothing tracked to stage.
33
+ if (isDirty(root)) {
34
+ stageAll(root);
35
+ }
36
+ const sha = commit(root, subject(root, slug), bodyArg(args) ?? undefined);
37
+ // Clear the control state: the in-flight markers, the per-worktree cursor (D28),
38
+ // and the session sentinel (STATE last, so "no session" flips exactly at the end).
39
+ // The tracked artifacts stay in place — only the ephemera go.
40
+ rmSync(seamPath(root, slug), { force: true });
41
+ rmSync(stepPath(root, slug), { force: true });
42
+ rmSync(spikePath(root, slug), { force: true });
43
+ if (slug !== null) {
44
+ // Drop the activeBuild key — JSON.stringify omits an `undefined` value, so the
45
+ // cursor is removed while the other local settings (auto, …) survive. Skipped
46
+ // under `--local`, where there is no cursor to clear.
47
+ setLocalSetting(root, 'activeBuild', undefined);
48
+ }
49
+ rmSync(join(sidecarDir(root), 'STATE'), { force: true });
50
+ const where = slug === null ? '.plumbbob/' : `.plumbbob/builds/${slug}/`;
51
+ process.stdout.write(`plumbbob: finished — ${sha.slice(0, 9)}. ${where} rides your branch into the PR. ` +
52
+ 'Run `/plumbbob:pb-plan` (or `plumbbob start "<title>"`) to frame the next goal.\n');
53
+ return 0;
54
+ }
55
+ // The CLI-owned final-commit subject (D34): `plumbbob: finish — <title>`, mirroring
56
+ // the step-checkpoint format exactly so one greppable shape spans the whole history.
57
+ // Falls back to a bare `plumbbob: finish` when intent.md carries no title.
58
+ function subject(root, slug) {
59
+ let title = null;
60
+ try {
61
+ title = parseTitle(readFileSync(intentPath(root, slug), 'utf8'));
62
+ }
63
+ catch {
64
+ title = null;
65
+ }
66
+ return title ? `plumbbob: finish — ${title}` : 'plumbbob: finish';
67
+ }
68
+ // `--body` reads the final-commit body from stdin (the single-quoted heredoc of
69
+ // D34), so the pb-finish skill can compose a proportional close-out message. Returns
70
+ // null when the flag is absent or stdin is empty — the commit then carries subject
71
+ // only. A read error (no stdin attached) degrades to null rather than throwing.
72
+ function bodyArg(args) {
73
+ if (!args.includes('--body')) {
74
+ return null;
75
+ }
76
+ try {
77
+ const raw = readFileSync(0, 'utf8').trimEnd();
78
+ return raw.length > 0 ? raw : null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ // Append the recorded checkpoints (baseline + each `step n <sha>`) to report.md as a
85
+ // `## Checkpoints` section, so the report — which now rides the branch into the PR —
86
+ // lists the SHAs. Best-effort: an unreadable checkpoints file yields an empty list.
87
+ function appendCheckpointShas(root, slug) {
88
+ let raw = '';
89
+ try {
90
+ raw = readFileSync(checkpointsPath(root, slug), 'utf8');
91
+ }
92
+ catch {
93
+ raw = '';
94
+ }
95
+ const lines = raw
96
+ .split('\n')
97
+ .map((l) => l.trim())
98
+ .filter((l) => l.length > 0)
99
+ .map((l) => `- ${l}`);
100
+ appendFileSync(reportPath(root, slug), ['', '## Checkpoints', '', ...lines, ''].join('\n'));
101
+ }
@@ -1,7 +1,12 @@
1
1
  // `plumbbob revert [--to n]` — git reset --hard to a checkpoint SHA (the most
2
2
  // recent step, or `--to n`, with the baseline as fallback), then remove untracked
3
- // files under the SEAM only. The sidecar is git-excluded (D17), so the reset
4
- // never touches it park lines and intent edits survive the revert (C4).
3
+ // files under the SEAM only. The artifact plane (`.plumbbob/builds/<slug>/`) is now
4
+ // TRACKED (D26), so a bare reset WOULD discard park lines and intent edits or, when
5
+ // reverting to a baseline that predates the build folder, delete the folder wholesale.
6
+ // So revert snapshots the sidecar to temp and restores it as uncommitted changes
7
+ // after the reset (D26), keeping C4/never-destroy intact across both cases. The
8
+ // untracked cleanup additionally whitelists the artifact plane, so no seam pattern
9
+ // can ever sweep away a build's own files.
5
10
  //
6
11
  // Plumbbob also installs its driver skills INTO the repo (.claude/skills/<driver>/
7
12
  // for a self-contained install), so a blunt reset would discard an out-of-seam
@@ -13,20 +18,21 @@ import { tmpdir } from 'node:os';
13
18
  import { join } from 'node:path';
14
19
  import { fileURLToPath } from 'node:url';
15
20
  import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
16
- import { checkpointsPath, hasSession, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
17
- import { matchesSeam } from "../lib/intent.js";
21
+ import { checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
22
+ import { isArtifactPath, matchesSeam } from "../lib/intent.js";
18
23
  export function revert(cwd, args) {
19
24
  const root = findRepoRoot(cwd);
20
25
  if (root === null || !hasSession(root)) {
21
26
  process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
22
27
  return 1;
23
28
  }
24
- const to = parseTo(args);
29
+ const { build: slug, rest } = resolveBuild(root, args);
30
+ const to = parseTo(rest);
25
31
  if (to === 'invalid') {
26
32
  process.stderr.write('plumbbob: revert --to needs a step number. Try: plumbbob revert --to 2.\n');
27
33
  return 1;
28
34
  }
29
- const checkpoints = readCheckpoints(root);
35
+ const checkpoints = readCheckpoints(root, slug);
30
36
  let sha;
31
37
  if (to === null) {
32
38
  sha = checkpoints.steps.at(-1)?.sha ?? checkpoints.baseline;
@@ -45,14 +51,14 @@ export function revert(cwd, args) {
45
51
  }
46
52
  // Compute untracked-in-seam BEFORE the reset (reset --hard leaves untracked and
47
53
  // ignored files alone, so they must be removed explicitly afterward).
48
- const seam = readSeamTokens(root);
49
- const toRemove = untrackedPaths(root).filter((p) => matchesSeam(p, seam));
54
+ const seam = readSeamTokens(root, slug);
55
+ const toRemove = untrackedPaths(root).filter((p) => matchesSeam(p, seam) && !isArtifactPath(p));
50
56
  resetPreserving(root, sha, plumbbobOwnedPaths(root));
51
57
  for (const rel of toRemove) {
52
58
  rmSync(join(root, rel), { force: true, recursive: true });
53
59
  }
54
- rmSync(seamPath(root), { force: true });
55
- rmSync(stepPath(root), { force: true });
60
+ rmSync(seamPath(root, slug), { force: true });
61
+ rmSync(stepPath(root, slug), { force: true });
56
62
  process.stdout.write(`plumbbob: reverted to ${sha.slice(0, 9)} — back at the boundary. Park lines and intent edits were preserved.\n`);
57
63
  return 0;
58
64
  }
@@ -99,10 +105,10 @@ function parseTo(args) {
99
105
  }
100
106
  return Number(raw);
101
107
  }
102
- function readCheckpoints(root) {
108
+ function readCheckpoints(root, slug) {
103
109
  let content = '';
104
110
  try {
105
- content = readFileSync(checkpointsPath(root), 'utf8');
111
+ content = readFileSync(checkpointsPath(root, slug), 'utf8');
106
112
  }
107
113
  catch {
108
114
  return { baseline: undefined, steps: [] };
@@ -122,9 +128,9 @@ function readCheckpoints(root) {
122
128
  }
123
129
  return { baseline, steps };
124
130
  }
125
- function readSeamTokens(root) {
131
+ function readSeamTokens(root, slug) {
126
132
  try {
127
- return readFileSync(seamPath(root), 'utf8')
133
+ return readFileSync(seamPath(root, slug), 'utf8')
128
134
  .split('\n')
129
135
  .map((l) => l.trim())
130
136
  .filter((l) => l.length > 0);
@@ -13,7 +13,7 @@ import { execFileSync } from 'node:child_process';
13
13
  import { existsSync } from 'node:fs';
14
14
  import { basename, dirname, join } from 'node:path';
15
15
  import { findRepoRoot } from "../lib/git.js";
16
- import { hasSession, inSpike, markSpike, clearSpike, stepPath } from "../lib/sidecar.js";
16
+ import { hasSession, inSpike, markSpike, clearSpike, resolveBuild, stepPath } from "../lib/sidecar.js";
17
17
  const DEFAULT_OPTIONS = ['a', 'b'];
18
18
  export function spike(cwd, args) {
19
19
  const root = findRepoRoot(cwd);
@@ -21,18 +21,19 @@ export function spike(cwd, args) {
21
21
  process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
22
22
  return 1;
23
23
  }
24
- const positionals = args.filter((a) => !a.startsWith('--'));
24
+ const { build: buildSlug, rest } = resolveBuild(root, args);
25
+ const positionals = rest.filter((a) => !a.startsWith('--'));
25
26
  if (positionals[0] === 'done') {
26
- return spikeDone(root);
27
+ return spikeDone(root, buildSlug);
27
28
  }
28
- return spikeStart(root, positionals);
29
+ return spikeStart(root, buildSlug, positionals);
29
30
  }
30
- function spikeStart(root, positionals) {
31
- if (inSpike(root)) {
31
+ function spikeStart(root, buildSlug, positionals) {
32
+ if (inSpike(root, buildSlug)) {
32
33
  process.stderr.write('plumbbob: already in a spike. Run `plumbbob spike done` to close it first.\n');
33
34
  return 1;
34
35
  }
35
- if (existsSync(stepPath(root))) {
36
+ if (existsSync(stepPath(root, buildSlug))) {
36
37
  process.stderr.write('plumbbob: spike starts from a settled boundary, but a step is in flight. ' +
37
38
  'A spike is a deliberate fork — checkpoint or revert the current step first.\n');
38
39
  return 1;
@@ -54,14 +55,14 @@ function spikeStart(root, positionals) {
54
55
  git(root, ['worktree', 'add', '-b', `spike/${slug}-${opt}`, path, 'HEAD']);
55
56
  created.push(path);
56
57
  }
57
- markSpike(root);
58
+ markSpike(root, buildSlug);
58
59
  process.stdout.write(`plumbbob: spiking — the main tree stays put. Experiment in the throwaway worktrees:\n${created
59
60
  .map((p) => ` ${p}`)
60
61
  .join('\n')}\nWhen you've decided, record the verdict in intent.md and run \`plumbbob spike done\`.\n`);
61
62
  return 0;
62
63
  }
63
- function spikeDone(root) {
64
- if (!inSpike(root)) {
64
+ function spikeDone(root, buildSlug) {
65
+ if (!inSpike(root, buildSlug)) {
65
66
  process.stderr.write('plumbbob: no active spike to close.\n');
66
67
  return 1;
67
68
  }
@@ -72,7 +73,7 @@ function spikeDone(root) {
72
73
  for (const branch of spikeBranches(root)) {
73
74
  git(root, ['branch', '-D', branch]);
74
75
  }
75
- clearSpike(root);
76
+ clearSpike(root, buildSlug);
76
77
  process.stdout.write('plumbbob: spike closed — worktrees and branches removed, back at the boundary. ' +
77
78
  'Record the verdict (which option won, and why) in intent.md before you `build`.\n');
78
79
  return 0;