plumbbob 0.8.18 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +50 -44
  3. package/dist/cli-core.js +35 -15
  4. package/dist/lib/agents.js +230 -112
  5. package/dist/lib/buildlog.js +50 -30
  6. package/dist/lib/buildlogsync.js +21 -12
  7. package/dist/lib/check.js +69 -35
  8. package/dist/lib/commitmsg.js +53 -32
  9. package/dist/lib/git.js +86 -31
  10. package/dist/lib/intent.js +91 -31
  11. package/dist/lib/latch.js +70 -39
  12. package/dist/lib/orient.js +78 -39
  13. package/dist/lib/plugins.js +11 -8
  14. package/dist/lib/settings.js +65 -32
  15. package/dist/lib/sidecar.js +320 -150
  16. package/dist/lib/templates.js +17 -6
  17. package/dist/verbs/agent.js +116 -55
  18. package/dist/verbs/build.js +27 -12
  19. package/dist/verbs/check.js +33 -7
  20. package/dist/verbs/checkpoint.js +153 -78
  21. package/dist/verbs/doctor.js +192 -95
  22. package/dist/verbs/finish.js +86 -45
  23. package/dist/verbs/handoff.js +56 -20
  24. package/dist/verbs/init.js +37 -12
  25. package/dist/verbs/park.js +14 -3
  26. package/dist/verbs/revert.js +64 -24
  27. package/dist/verbs/spike.js +89 -30
  28. package/dist/verbs/start.js +72 -38
  29. package/dist/verbs/status.js +44 -17
  30. package/dist/verbs/turn.js +88 -45
  31. package/dist/verbs/use.js +20 -9
  32. package/package.json +1 -1
  33. package/skills/{pb-build → build}/SKILL.md +25 -25
  34. package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
  35. package/skills/{pb-finish → finish}/SKILL.md +3 -3
  36. package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
  37. package/skills/{pb-park → park}/SKILL.md +2 -2
  38. package/skills/{pb-plan → plan}/SKILL.md +11 -11
  39. package/skills/{pb-refine → refine}/SKILL.md +7 -7
  40. package/skills/{pb-revert → revert}/SKILL.md +2 -2
  41. package/skills/{pb-spike → spike}/SKILL.md +4 -4
  42. package/skills/{pb-status → status}/SKILL.md +3 -3
  43. package/skills/{pb-step → step}/SKILL.md +9 -9
  44. package/skills/{pb-verify → verify}/SKILL.md +8 -8
  45. package/templates/build-log.md +7 -7
  46. package/templates/intent.md +8 -8
  47. package/templates/spike-report.md +1 -1
@@ -1,17 +1,27 @@
1
- // `plumbbob finish` (D9/D68) — 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 Conventional `chore(<scope>): finish` subject, its
8
- // `plumbbob finish` identifier riding the body marker (D68).
1
+ // `plumbbob finish` — the close-out. It appends the checkpoint SHAs and per-step
2
+ // stats to report.md, makes the final commit, and clears the control state (the
3
+ // untracked session and step markers under `.plumbbob/`). The build folder is NOT
4
+ // deleted — the tracked `.plumbbob/builds/<slug>/` folder IS the archive: it
5
+ // merges with the branch and shows up in the PR, so nothing is copied into a
6
+ // separate local archive. A missing report never blocks the exit — guidance
7
+ // offers the artifact, it does not wall the door. The git footprint stays
8
+ // additive: one forward commit under the Conventional `chore(<scope>): finish`
9
+ // subject, with the `plumbbob finish` identifier riding a marker line at the head
10
+ // of the body so `git log --grep plumbbob` still finds it.
9
11
  import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
10
12
  import { join } from 'node:path';
11
13
  import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
12
14
  import { buildScope, checkpointsPath, clearTick, hasSession, intentPath, readStats, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
13
15
  import { conventionalSubject, withMarker } from "../lib/commitmsg.js";
14
16
  import { parseBuildScope } from "../lib/intent.js";
17
+ /**
18
+ * Close out the active build: report tail, final commit, control-state cleanup.
19
+ *
20
+ * Requires an active session (the `.plumbbob/STATE` sentinel). When report.md
21
+ * exists it gains a `## Checkpoints` SHA list and a `## Stats` table first; when
22
+ * it doesn't, finish notes the absence and proceeds — the exit is never gated on
23
+ * a report.
24
+ */
15
25
  export function finish(cwd, args = []) {
16
26
  const root = findRepoRoot(cwd);
17
27
  if (root === null || !hasSession(root)) {
@@ -25,46 +35,56 @@ export function finish(cwd, args = []) {
25
35
  }
26
36
  else {
27
37
  process.stderr.write('plumbbob: note — no report.md found; finishing without one ' +
28
- '(/pb-finish normally writes the report first). No gate (D9).\n');
38
+ '(/plumbbob:finish normally writes the report first). No gate (D9).\n');
29
39
  }
30
- // The final commit (D34): stage the report just written plus the build folder's
31
- // tail (the last step's checkpoint line lands one commit late, D37) and commit it
32
- // under the greppable `finish` subject. `--allow-empty` (via `commit`) still marks
33
- // the narrative endpoint when the tree is already clean, or under `--local`, where
34
- // the whole sidecar is excluded and there is nothing tracked to stage.
40
+ // The final commit: stage the report just written plus the build folder's tail —
41
+ // a step's commit sweeps its own bookkeeping along with the work, so the last
42
+ // step's `checkpoints` line lands one commit late and finish absorbs it — and
43
+ // commit under the `finish` subject. `--allow-empty` (via `commit`) still marks
44
+ // the narrative endpoint when the tree is already clean, or under `--local`,
45
+ // where the whole sidecar is git-excluded and there is nothing tracked to stage.
35
46
  if (isDirty(root)) {
36
47
  stageAll(root);
37
48
  }
38
49
  const sha = commit(root, subject(root, slug), withMarker('plumbbob finish', bodyArg(args) ?? undefined));
39
- // Clear the control state: the in-flight markers first, then the session sentinel
40
- // last (so "no session" flips exactly at the end). Deleting STATE also drops the
41
- // cursor (D28) — cursor and session share the one file now, so a single delete does
42
- // both. The tracked artifacts stay in place — only the ephemera go.
50
+ // Clear the control state: the in-flight markers first, then the session
51
+ // sentinel last (so "no session" flips exactly at the end). Deleting STATE also
52
+ // drops the active-build cursor — cursor and session share that one file, so a
53
+ // single delete does both. The tracked artifacts stay in place — only the
54
+ // ephemera go.
43
55
  rmSync(seamPath(root, slug), { force: true });
44
56
  rmSync(stepPath(root, slug), { force: true });
45
57
  rmSync(spikePath(root, slug), { force: true });
46
- // The latch's per-build entry stamp and the one-turn grant go with the session
47
- // (D64/D65): a grant is one-turn by construction, but the session's last tick is
48
- // the last time it was rewritten — left behind, a stale `auto` could self-approve
49
- // the next session's first landing.
58
+ // The checkpoint latch's per-build entry stamp and the one-turn self-approval
59
+ // grant go with the session: a grant lives one turn by construction, but only
60
+ // because every tick rewrites it — the session's last tick was the last rewrite,
61
+ // so left behind, a stale `auto` could self-approve the next session's first
62
+ // landing.
50
63
  clearTick(root, slug);
51
64
  setGrant(root, null);
52
65
  rmSync(join(sidecarDir(root), 'STATE'), { force: true });
53
66
  const where = slug === null ? '.plumbbob/' : `.plumbbob/builds/${slug}/`;
54
67
  process.stdout.write(`plumbbob: finished — ${sha.slice(0, 9)}. ${where} rides your branch into the PR. ` +
55
- 'Run `/pb-plan` (or `plumbbob start "<title>"`) to frame the next goal.\n');
68
+ 'Run `/plumbbob:plan` (or `plumbbob start "<title>"`) to frame the next goal.\n');
56
69
  return 0;
57
70
  }
58
- // The CLI-owned final-commit subject (D68): `chore(<scope>): finish`, the scope
59
- // resolved through the same build-default fallback chain as plan/step subjects
60
- // (D3/D4): the `**Scope:**` header field, else the build slug, else bare (e.g.
61
- // `--local`). The `plumbbob finish` identifier rides the body marker, not the
62
- // subject.
71
+ /**
72
+ * The CLI-owned final-commit subject: `chore(<scope>): finish`.
73
+ *
74
+ * The scope resolves through the same build-default fallback chain as the plan
75
+ * and step subjects: the intent.md `**Scope:**` header field, else the build
76
+ * slug, else bare (e.g. `--local`). The `plumbbob finish` identifier rides the
77
+ * body marker, not the subject.
78
+ */
63
79
  function subject(root, slug) {
64
80
  return conventionalSubject({ type: 'chore', scope: buildDefaultScope(root, slug), description: 'finish' });
65
81
  }
66
- // See checkpoint.ts's twin of this helper (same fallback, this verb's own `slug`
67
- // resolution instead of the active-build cursor).
82
+ /**
83
+ * Resolve the commit scope: the intent.md `**Scope:**` header, else the slug.
84
+ *
85
+ * Twin of checkpoint.ts's helper — same fallback chain, but with this verb's own
86
+ * `slug` resolution instead of the active-build cursor.
87
+ */
68
88
  function buildDefaultScope(root, slug) {
69
89
  try {
70
90
  const fromHeader = parseBuildScope(readFileSync(intentPath(root, slug), 'utf8'));
@@ -77,12 +97,17 @@ function buildDefaultScope(root, slug) {
77
97
  }
78
98
  return buildScope(slug);
79
99
  }
80
- // `--body` reads the final-commit body from stdin (the single-quoted heredoc of
81
- // D34), so the pb-finish skill can compose a proportional close-out message. Returns
82
- // null when the flag is absent or stdin is empty — the commit then carries subject
83
- // only. Reading fd 0 blocks until EOF, which the heredoc supplies; a read error (no
84
- // stdin attached) degrades to null, and an interactive TTY — which would never send
85
- // EOF — skips the read instead of hanging (twin of checkpoint.ts's guard).
100
+ /**
101
+ * Read the `--body` final-commit body from stdin, or null.
102
+ *
103
+ * The body arrives as a single-quoted stdin heredoc — the CLI owns every commit
104
+ * subject, but the finish skill composes a proportional close-out body this
105
+ * way. Returns null when the flag is absent or stdin is empty; the commit then
106
+ * carries subject only. Reading fd 0 blocks until EOF, which the heredoc
107
+ * supplies; a read error (no stdin attached) degrades to null, and an
108
+ * interactive TTY — which would never send EOF — skips the read instead of
109
+ * hanging (twin of checkpoint.ts's guard).
110
+ */
86
111
  function bodyArg(args) {
87
112
  if (!args.includes('--body') || process.stdin.isTTY === true) {
88
113
  return null;
@@ -95,10 +120,14 @@ function bodyArg(args) {
95
120
  return null;
96
121
  }
97
122
  }
98
- // Roll the per-step receipts (research/07 Build 2b) into report.md as a `## Stats`
99
- // table — one row per step plus totals, so "is the loop worth it?" is a table,
100
- // not a feeling. Silently skipped when nothing accrued (an old build, a build
101
- // with no red/revert/drift and no `build <n>` stamps has nothing to say).
123
+ /**
124
+ * Roll the per-step receipts into report.md as a `## Stats` table.
125
+ *
126
+ * One row per step plus totals — red checks, drift warnings, reverts,
127
+ * wall-clock — so "is the loop worth it?" is a table, not a feeling. Silently
128
+ * skipped when nothing accrued (a build with no red/revert/drift and no
129
+ * `build <n>` stamps has nothing to say).
130
+ */
102
131
  function appendStats(root, slug) {
103
132
  const stats = readStats(root, slug);
104
133
  const steps = Object.keys(stats)
@@ -129,6 +158,10 @@ function appendStats(root, slug) {
129
158
  '',
130
159
  ].join('\n'));
131
160
  }
161
+ /**
162
+ * Milliseconds from a step's entry stamp to its landing stamp, or null when
163
+ * either timestamp is missing or malformed.
164
+ */
132
165
  function wallClockMs(s) {
133
166
  if (s.startedAt === undefined || s.landedAt === undefined) {
134
167
  return null;
@@ -136,17 +169,25 @@ function wallClockMs(s) {
136
169
  const ms = Date.parse(s.landedAt) - Date.parse(s.startedAt);
137
170
  return Number.isFinite(ms) && ms >= 0 ? ms : null;
138
171
  }
139
- // `—` when unknown (a hand-built step never ran `build <n>`, so it has no
140
- // startedAt), `<1m` under a minute, whole minutes otherwise.
172
+ /**
173
+ * Format a wall-clock duration for the stats table.
174
+ *
175
+ * `—` when unknown (a hand-built step never ran `build <n>`, so it has no
176
+ * startedAt), `<1m` under a minute, whole minutes otherwise.
177
+ */
141
178
  function formatWall(ms) {
142
179
  if (ms === null) {
143
180
  return '—';
144
181
  }
145
182
  return ms < 60_000 ? '<1m' : `${Math.round(ms / 60_000)}m`;
146
183
  }
147
- // Append the recorded checkpoints (baseline + each `step n <sha>`) to report.md as a
148
- // `## Checkpoints` section, so the report — which now rides the branch into the PR —
149
- // lists the SHAs. Best-effort: an unreadable checkpoints file yields an empty list.
184
+ /**
185
+ * Append the recorded checkpoints (baseline + each `step n <sha>`) to report.md
186
+ * as a `## Checkpoints` section.
187
+ *
188
+ * The report rides the branch into the PR, so it carries the SHAs with it.
189
+ * Best-effort: an unreadable checkpoints file yields an empty list.
190
+ */
150
191
  function appendCheckpointShas(root, slug) {
151
192
  let raw = '';
152
193
  try {
@@ -1,15 +1,24 @@
1
- // `plumbbob handoff` — print the standardized build hand-off block (D67): the
1
+ // `plumbbob handoff` — print the standardized build hand-off block: the
2
2
  // "state / choice / what's next" the human sees at each step boundary. Read-only,
3
3
  // no state change. It derives the moment from the session — a step in flight ⇒ the
4
4
  // pause block (built → looks-good/needs-work → next), none ⇒ the post-checkpoint
5
5
  // boundary block — and renders the next undone step plus its advisory `- model:`
6
- // recommendation straight from intent.md. This block lived as prose in pb-build's
7
- // SKILL.md; owning it here keeps the skill from drifting out of sync with `status`,
8
- // which renders the same next-step detail (both read `parseSteps`).
6
+ // recommendation straight from intent.md, the build's plan document. Owning the
7
+ // block here, rather than as prose in the build skill, keeps the skill from
8
+ // drifting out of sync with `status`, which renders the same next-step detail
9
+ // (both read `parseSteps`).
9
10
  import { readFileSync } from 'node:fs';
10
11
  import { findRepoRoot } from "../lib/git.js";
11
12
  import { checkpointsPath, hasSession, intentPath, resolveBuild, stepPath } from "../lib/sidecar.js";
12
13
  import { parseLastCheckpoint, parseSteps } from "../lib/orient.js";
14
+ /**
15
+ * Print the hand-off block for the resolved build; return the exit code.
16
+ *
17
+ * Requires an active session (the STATE sentinel under `.plumbbob/`). Which
18
+ * moment to render is derived, not passed: a step in flight yields the pause
19
+ * block, none yields the boundary block, and a fresh session with nothing to
20
+ * report yields only the forward pointer.
21
+ */
13
22
  export function handoff(cwd, args = []) {
14
23
  const root = findRepoRoot(cwd);
15
24
  if (root === null || !hasSession(root)) {
@@ -40,9 +49,12 @@ export function handoff(cwd, args = []) {
40
49
  process.stdout.write(`${block}\n`);
41
50
  return 0;
42
51
  }
43
- // The pause block: the step is built and awaiting approval. The opener is neutral
44
- // (the CLI cannot vouch for the diff), pointing at the diff and self-review the
45
- // skill shows above it.
52
+ /**
53
+ * The pause block: the step is built and awaiting approval.
54
+ *
55
+ * The opener is neutral (the CLI cannot vouch for the diff), pointing at the
56
+ * diff and self-review the skill shows above it.
57
+ */
46
58
  function pauseBlock(step, title, nextUp) {
47
59
  return [
48
60
  `Step ${step}${paren(title)} is built. Review the diff and self-review above, then:`,
@@ -52,38 +64,57 @@ function pauseBlock(step, title, nextUp) {
52
64
  nextUpLine(nextUp),
53
65
  ].join('\n');
54
66
  }
55
- // The boundary block: the checkpoint has landed and the loop is back at the
56
- // step boundary, pointing at what to build next.
67
+ /**
68
+ * The boundary block: the checkpoint has landed and the loop is back at the
69
+ * step boundary, pointing at what to build next.
70
+ */
57
71
  function boundaryBlock(step, title, nextUp) {
58
72
  return [`Step ${step}${paren(title)} checkpointed — back at the boundary.`, '', nextUpLine(nextUp)].join('\n');
59
73
  }
60
- // The forward pointer, shared by both blocks: the next undone step, its title, and
61
- // its advisory `- model:` recommendation (as just the model token for the `/model`
62
- // call — the full rationale lives on the `status` dashboard). No next step ⇒ the
63
- // finish/step nudge instead.
74
+ /**
75
+ * The forward pointer, shared by both blocks: the next undone step, its title,
76
+ * and its advisory `- model:` recommendation.
77
+ *
78
+ * The recommendation renders as just the model token for the `/model` call —
79
+ * the full rationale lives on the `status` dashboard. No next step ⇒ the
80
+ * finish/step nudge instead.
81
+ */
64
82
  function nextUpLine(nextUp) {
65
83
  if (nextUp === undefined) {
66
- return 'No planned steps remain — /pb-step to add an increment, or /pb-finish.';
84
+ return 'No planned steps remain — /plumbbob:step to add an increment, or /plumbbob:finish.';
67
85
  }
68
86
  const head = `Next up: step ${nextUp.n}${paren(nextUp.title)}`;
69
87
  const token = modelToken(nextUp.model);
70
- return token === null ? `${head} — /pb-build to start it.` : `${head} · model: ${token} — /model ${token} then /pb-build.`;
88
+ return token === null ? `${head} — /plumbbob:build to start it.` : `${head} · model: ${token} — /model ${token} then /plumbbob:build.`;
71
89
  }
72
- // A step's title in parentheses, or nothing when the title is empty.
90
+ /**
91
+ * A step's title in parentheses, or nothing when the title is empty.
92
+ */
73
93
  function paren(title) {
74
94
  return title !== null && title.length > 0 ? ` (${title})` : '';
75
95
  }
96
+ /**
97
+ * The title of step `n` from the parsed steps, or null when the step is
98
+ * unknown or untitled.
99
+ */
76
100
  function titleOf(steps, n) {
77
101
  return steps.find((s) => s.n === n)?.title ?? null;
78
102
  }
79
- // The model token from a `- model:` recommendation — the first word, so a
80
- // `model: opus — <rationale>` line yields `opus` for the `/model` call. Null when
81
- // there is no recommendation, or it degraded to whitespace (folding the null model
82
- // into the same guard keeps both branches live).
103
+ /**
104
+ * The model token from a `- model:` recommendation — the first word, so a
105
+ * `model: opus — <rationale>` line yields `opus` for the `/model` call.
106
+ *
107
+ * Null when there is no recommendation, or it degraded to whitespace (folding
108
+ * the null model into the same guard keeps both branches live).
109
+ */
83
110
  function modelToken(model) {
84
111
  const first = model?.trim().split(/\s+/)[0];
85
112
  return first !== undefined && first.length > 0 ? first : null;
86
113
  }
114
+ /**
115
+ * Read a file as UTF-8, or return '' when it is missing or unreadable — an
116
+ * absent sidecar file reads as empty rather than throwing.
117
+ */
87
118
  function readOr(path) {
88
119
  try {
89
120
  return readFileSync(path, 'utf8');
@@ -92,6 +123,11 @@ function readOr(path) {
92
123
  return '';
93
124
  }
94
125
  }
126
+ /**
127
+ * The in-flight step number from the STEP marker — the flat one-line file in
128
+ * the build folder that records which step is open. Null when no step is in
129
+ * flight or the file holds anything but a bare number.
130
+ */
95
131
  function readStep(path) {
96
132
  try {
97
133
  const raw = readFileSync(path, 'utf8').trim();
@@ -2,14 +2,14 @@
2
2
  // in-place skills-directory plugin. It symlinks the installed package into
3
3
  // ~/.claude/skills/plumbbob, where Claude Code discovers it as `plumbbob@skills-dir`
4
4
  // — the skills load namespaced (`/plumbbob:*`) and the post-edit hook auto-registers
5
- // from hooks/hooks.json. This is the deliberate non-marketplace install path — kept
6
- // first-class, not deprecated: it serves an `npm i -g` global, local dev, and clients
7
- // predating plugins, and keeps plumbbob usable as a standalone CLI (and, later, under
8
- // other agents) rather than Claude-marketplace-only. The marketplace plugin is the
5
+ // from hooks/hooks.json. This non-marketplace install path is first-class by design:
6
+ // it serves an `npm i -g` global, local dev, and clients predating plugins, and it
7
+ // keeps plumbbob usable as a standalone CLI outside any one host rather than
8
+ // Claude-marketplace-only. The marketplace plugin is the
9
9
  // co-equal self-contained alternative (it ships skills AND the `plumbbob` CLI on PATH
10
10
  // via bin/, so it needs neither `npm i -g` nor `init`). The two are mutually
11
11
  // exclusive: both register a plugin named `plumbbob`, and a double-install collides
12
- // over the /plumbbob:* namespace (skills can drop to flat `/pb-status` names). So init
12
+ // over the /plumbbob:* namespace (skills can drop to flat `/status` names). So init
13
13
  // REFUSES when a marketplace plumbbob is already installed — `--force` overrides
14
14
  // (the dev-install path uses it). Global-only by design: install scope is NOT session
15
15
  // scope — sessions stay per-project via `plumbbob start`. Idempotent + reversible
@@ -19,17 +19,28 @@ import { homedir } from 'node:os';
19
19
  import { join } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { marketplacePlumbbob } from "../lib/plugins.js";
22
- // The installed package root (parent of .claude-plugin/, skills/, hooks/, dist/),
23
- // off this module's URL — the global install when run as the published bin, the
24
- // checkout in dev. No trailing slash, so it compares clean against a readlink.
22
+ /**
23
+ * The installed package root — parent of .claude-plugin/, skills/, hooks/, dist/.
24
+ *
25
+ * Derived from this module's URL: the global install when run as the published
26
+ * bin, the checkout in dev. No trailing slash, so it compares clean against a
27
+ * readlink.
28
+ */
25
29
  function packageRoot() {
26
30
  return fileURLToPath(new URL('../../', import.meta.url)).replace(/\/+$/, '');
27
31
  }
32
+ /**
33
+ * The symlink location Claude Code scans for skills-directory plugins.
34
+ */
28
35
  function linkPath(home) {
29
36
  return join(home, '.claude', 'skills', 'plumbbob');
30
37
  }
31
- // lstat-based existence: true even for a broken symlink (existsSync follows the
32
- // link and would miss one whose target moved).
38
+ /**
39
+ * Whether anything exists at `path`, including a broken symlink.
40
+ *
41
+ * lstat-based: existsSync follows the link and would miss one whose target
42
+ * moved.
43
+ */
33
44
  function present(path) {
34
45
  try {
35
46
  lstatSync(path);
@@ -39,7 +50,9 @@ function present(path) {
39
50
  return false;
40
51
  }
41
52
  }
42
- // The symlink's target (no trailing slash), or null if `path` is not a symlink.
53
+ /**
54
+ * The symlink's target (no trailing slash), or null if `path` is not a symlink.
55
+ */
43
56
  function symlinkTarget(path) {
44
57
  try {
45
58
  return lstatSync(path).isSymbolicLink() ? readlinkSync(path).replace(/\/+$/, '') : null;
@@ -48,6 +61,14 @@ function symlinkTarget(path) {
48
61
  return null;
49
62
  }
50
63
  }
64
+ /**
65
+ * Link the installed package into ~/.claude/skills/plumbbob, or `--uninstall` it.
66
+ *
67
+ * Refuses when a marketplace plumbbob plugin is already installed — two plugins
68
+ * named `plumbbob` collide over the /plumbbob:* namespace — unless `--force`.
69
+ * Repoints a stale link from an earlier install location; already-linked is a
70
+ * clean exit 0.
71
+ */
51
72
  export function init(args) {
52
73
  const home = process.env.HOME ?? homedir();
53
74
  const link = linkPath(home);
@@ -59,7 +80,7 @@ export function init(args) {
59
80
  if (market.length > 0 && !args.includes('--force')) {
60
81
  process.stderr.write(`plumbbob: a marketplace plumbbob plugin is already installed (${market.join(', ')}).\n` +
61
82
  'plumbbob: it already provides the skills (`/plumbbob:*`) and the `plumbbob` CLI on PATH (the plugin bin/) — no `plumbbob init` needed.\n' +
62
- `plumbbob: linking ${link} would register a SECOND plugin named \`plumbbob\`; the two collide over the /plumbbob:* namespace and skills can drop to flat names (\`/pb-status\`).\n` +
83
+ `plumbbob: linking ${link} would register a SECOND plugin named \`plumbbob\`; the two collide over the /plumbbob:* namespace and skills can drop to flat names (\`/status\`).\n` +
63
84
  'plumbbob: to use the skills-dir link instead, first remove the marketplace one (`/plugin uninstall plumbbob@<marketplace>`), or re-run with `--force` if you know what you are doing.\n');
64
85
  return 1;
65
86
  }
@@ -82,6 +103,10 @@ export function init(args) {
82
103
  'plumbbob: nothing else under ~ is touched and settings.json is left alone. Sessions are per-project — run `plumbbob start "<goal>"` in any repo, then `plumbbob doctor` to verify.\n');
83
104
  return 0;
84
105
  }
106
+ /**
107
+ * Remove the skills-directory link, leaving anything that is not a plumbbob
108
+ * symlink untouched.
109
+ */
85
110
  function uninstall(link) {
86
111
  if (!present(link)) {
87
112
  process.stdout.write(`plumbbob: nothing to uninstall — no link at ${link}.\n`);
@@ -1,10 +1,21 @@
1
- // `plumbbob park "<text>"` — the dumb capture path: append one raw line under
2
- // the build-log's Park list. No model turn, no composition (that is /pb-park's job).
3
- // Capture is not a transition, so it runs in any context (terminal or in-session).
1
+ // `plumbbob park "<text>"` — the raw capture path for a thought you don't want
2
+ // to chase mid-step: append one flat line under the "## Park list" section of
3
+ // build-log.md (the build's human-facing ledger). Capture stays dumb by design —
4
+ // a grep-readable append, no markdown parsing, no model turn (wording the line
5
+ // well is /plumbbob:park's job); triage waits for a step boundary, via harvest.
6
+ // Capture is not a state transition, so it runs in any context (terminal or
7
+ // in-session).
4
8
  import { readFileSync, writeFileSync } from 'node:fs';
5
9
  import { findRepoRoot } from "../lib/git.js";
6
10
  import { hasSession, buildLogPath } from "../lib/sidecar.js";
7
11
  import { appendToSection } from "../lib/buildlog.js";
12
+ /**
13
+ * Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
14
+ *
15
+ * All non-flag args join into a single line. Refuses when there is no active
16
+ * session, when the text is empty, or when build-log.md lacks a "## Park list"
17
+ * section — a park line must never vanish silently.
18
+ */
8
19
  export function park(cwd, args) {
9
20
  const root = findRepoRoot(cwd);
10
21
  if (root === null || !hasSession(root)) {
@@ -1,12 +1,14 @@
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 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.
3
+ // files under the SEAM only (the seam is the in-flight step's flat list of
4
+ // granted edit paths). The artifact plane — the tracked `.plumbbob/builds/<slug>/`
5
+ // folder holding intent, build-log, checkpoints, and park lines — would not
6
+ // survive a bare reset: it would discard park lines and intent edits, or, when
7
+ // reverting to a baseline that predates the build folder, delete the folder
8
+ // wholesale. So revert snapshots the sidecar to temp and restores it as
9
+ // uncommitted changes after the reset — a rewind must never destroy recorded
10
+ // work, in either case. The untracked cleanup additionally whitelists the
11
+ // artifact plane, so no seam pattern can ever sweep away a build's own files.
10
12
  //
11
13
  // Plumbbob also installs its driver skills INTO the repo (.claude/skills/<driver>/
12
14
  // for a self-contained install), so a blunt reset would discard an out-of-seam
@@ -21,6 +23,15 @@ import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
21
23
  import { bumpStepStat, checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
22
24
  import { isArtifactPath, matchesSeam } from "../lib/intent.js";
23
25
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
26
+ /**
27
+ * Rewind to a recorded checkpoint and return the build to the boundary.
28
+ *
29
+ * Requires an active session (the `.plumbbob/STATE` sentinel). Resolves the
30
+ * target SHA from the checkpoints ledger (latest step, `--to n`, or the
31
+ * baseline), resets hard while preserving plumbbob-owned paths, removes
32
+ * untracked files inside the seam, clears the in-flight STEP/SEAM markers, and
33
+ * re-renders the build-log. Exit 1 with a hint on any refusal.
34
+ */
24
35
  export function revert(cwd, args) {
25
36
  const root = findRepoRoot(cwd);
26
37
  if (root === null || !hasSession(root)) {
@@ -54,9 +65,9 @@ export function revert(cwd, args) {
54
65
  // ignored files alone, so they must be removed explicitly afterward).
55
66
  const seam = readSeamTokens(root, slug);
56
67
  const toRemove = untrackedPaths(root).filter((p) => matchesSeam(p, seam) && !isArtifactPath(p));
57
- // The dogfood receipt (research/07 2b): a revert against an in-flight step is a
58
- // datapoint — read the marker before it goes, bump after the reset (the sidecar
59
- // is preserved through it, so the write survives).
68
+ // A revert against an in-flight step is a datapoint the finish report's stats
69
+ // table wants — read the STEP marker before it goes, bump the counter after
70
+ // the reset (the sidecar is preserved through it, so the write survives).
60
71
  const inFlight = readInFlightStep(root, slug);
61
72
  resetPreserving(root, sha, plumbbobOwnedPaths(root));
62
73
  for (const rel of toRemove) {
@@ -67,19 +78,26 @@ export function revert(cwd, args) {
67
78
  if (inFlight !== null) {
68
79
  bumpStepStat(root, slug, inFlight, 'reverts');
69
80
  }
70
- // The step is abandoned: the build-log returns to the boundary and its mirror
71
- // re-renders from the preserved intent.md (D69) — revert keeps intent edits (D26),
72
- // so intent's checkboxes stay the truth to reflect. Best-effort, like the rest.
81
+ // The step is abandoned: the build-log's Current-step line returns to the
82
+ // boundary and its Steps mirror re-renders from the preserved intent.md —
83
+ // revert keeps intent edits, so intent's checkboxes stay the truth to reflect.
84
+ // Best-effort, like every build-log write.
73
85
  syncBuildLogState(root, slug, AT_BOUNDARY);
74
86
  process.stdout.write(`plumbbob: reverted to ${sha.slice(0, 9)} — back at the boundary. Park lines and intent edits were preserved.\n`);
75
87
  return 0;
76
88
  }
77
- // The repo paths that belong to plumbbob, not to the work being reverted: the
78
- // sidecar (already git-excluded, listed so revert is robust even where `.plumbbob/`
79
- // was tracked by mistake) and each installed driver skill under .claude/skills/.
80
- // Skill names come from plumbbob's own bundled `skills/` dir — the same source
81
- // `setup` copies from — so only plumbbob's own skills are protected, never the
82
- // user's. Only paths that currently exist are returned.
89
+ /**
90
+ * The repo paths that belong to plumbbob, not to the work being reverted.
91
+ *
92
+ * They are the sidecar (already git-excluded, listed so revert is robust even
93
+ * where `.plumbbob/` was tracked by mistake) and each installed driver skill
94
+ * under .claude/skills/, named from plumbbob's own bundled `skills/` dir.
95
+ *
96
+ * The match is by name alone, and since the skills dropped their `pb-` prefix
97
+ * those names are ordinary words — a user's own `.claude/skills/plan/` is
98
+ * carried across the reset too. That errs toward keeping work (C4), so it is
99
+ * the safe direction to be wrong in. Only paths that currently exist are returned.
100
+ */
83
101
  function plumbbobOwnedPaths(root) {
84
102
  const paths = [sidecarDir(root)];
85
103
  try {
@@ -92,9 +110,13 @@ function plumbbobOwnedPaths(root) {
92
110
  }
93
111
  return paths.filter((p) => existsSync(p));
94
112
  }
95
- // `git reset --hard <sha>` is repo-wide, so paths that must survive it are copied
96
- // to a temp snapshot first, then copied back over whatever the reset produced.
97
- // Restoring on top (no pre-delete) keeps the live sidecar safe if a copy throws.
113
+ /**
114
+ * `git reset --hard <sha>` with the given paths carried across it.
115
+ *
116
+ * The reset is repo-wide, so paths that must survive it are copied to a temp
117
+ * snapshot first, then copied back over whatever the reset produced. Restoring
118
+ * on top (no pre-delete) keeps the live sidecar safe if a copy throws.
119
+ */
98
120
  function resetPreserving(root, sha, preserve) {
99
121
  const snap = mkdtempSync(join(tmpdir(), 'plumbbob-revert-'));
100
122
  try {
@@ -106,6 +128,10 @@ function resetPreserving(root, sha, preserve) {
106
128
  rmSync(snap, { force: true, recursive: true });
107
129
  }
108
130
  }
131
+ /**
132
+ * Parse `--to <n>`: the step number, null when the flag is absent, or
133
+ * 'invalid' when the flag lacks a numeric argument.
134
+ */
109
135
  function parseTo(args) {
110
136
  const idx = args.indexOf('--to');
111
137
  if (idx === -1) {
@@ -117,6 +143,12 @@ function parseTo(args) {
117
143
  }
118
144
  return Number(raw);
119
145
  }
146
+ /**
147
+ * Parse the build's CHECKPOINTS record into a baseline sha and per-step shas.
148
+ *
149
+ * A missing or unreadable file yields an empty record — the caller then refuses
150
+ * with its own message rather than crashing here.
151
+ */
120
152
  function readCheckpoints(root, slug) {
121
153
  let content = '';
122
154
  try {
@@ -140,6 +172,11 @@ function readCheckpoints(root, slug) {
140
172
  }
141
173
  return { baseline, steps };
142
174
  }
175
+ /**
176
+ * Read the in-flight step's seam — its edit-grant paths, one per line.
177
+ *
178
+ * A missing or unreadable SEAM contributes nothing: no tokens, never an error.
179
+ */
143
180
  function readSeamTokens(root, slug) {
144
181
  try {
145
182
  return readFileSync(seamPath(root, slug), 'utf8')
@@ -151,8 +188,11 @@ function readSeamTokens(root, slug) {
151
188
  return [];
152
189
  }
153
190
  }
154
- // The in-flight STEP number, or null when none — read for the revert receipt
155
- // before the marker is cleared.
191
+ /**
192
+ * The in-flight STEP number, or null when none.
193
+ *
194
+ * Read for the revert receipt before the marker is cleared.
195
+ */
156
196
  function readInFlightStep(root, slug) {
157
197
  try {
158
198
  const raw = readFileSync(stepPath(root, slug), 'utf8').trim();