plumbbob 0.4.14 → 0.5.4

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.
@@ -4,12 +4,18 @@ import { fileURLToPath } from 'node:url';
4
4
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { findRepoRoot, hasCommit, headSha, isDirty } from "../lib/git.js";
7
- import { sidecarDir, checkpointsPath, configPath, intentPath, buildLogPath, beginSession, hasSession, excludeSidecar, } from "../lib/sidecar.js";
8
- const DEFAULT_CHECK = 'pnpm run check';
7
+ import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, } from "../lib/sidecar.js";
8
+ import { settingsPath, setLocalSetting } from "../lib/settings.js";
9
+ // D24/D32: the gate is checkride unless a `check` setting overrides it, so
10
+ // settings.json seeds with no `check` key at all — absence IS the default. The
11
+ // templates' {{CHECK}} line is a human-readable echo of that.
12
+ const CHECK_ECHO = 'checkride (set a "check" key in .plumbbob/settings.json to override)';
9
13
  export function start(cwd, args) {
10
14
  const positionals = args.filter((a) => !a.startsWith('--'));
11
15
  const allowDirty = args.includes('--allow-dirty');
16
+ const local = args.includes('--local');
12
17
  const title = (positionals[0] ?? '').trim();
18
+ const slug = (flagValue(args, '--slug') ?? datedSlug(title)).trim();
13
19
  if (title.length === 0) {
14
20
  process.stderr.write('plumbbob: start needs a title. Try: plumbbob start "what you are building".\n');
15
21
  return 1;
@@ -34,31 +40,62 @@ export function start(cwd, args) {
34
40
  }
35
41
  process.stderr.write('plumbbob: WARNING --allow-dirty: recording HEAD as baseline with a dirty tree. A later revert-to-baseline will DISCARD the uncommitted work.\n');
36
42
  }
43
+ if (!local && slug.length === 0) {
44
+ process.stderr.write(`plumbbob: could not derive a build slug from "${title}". Pass --slug <name>, or retitle with letters or digits.\n`);
45
+ return 1;
46
+ }
47
+ if (!local && listBuilds(root).includes(slug)) {
48
+ process.stderr.write(`plumbbob: a build named "${slug}" already exists in .plumbbob/builds/. Retitle, or pass --slug <name> to choose another.\n`);
49
+ return 1;
50
+ }
37
51
  const sha = headSha(root);
38
- const check = detectCheck(root);
39
52
  mkdirSync(sidecarDir(root), { recursive: true });
40
53
  beginSession(root);
41
- writeFileSync(checkpointsPath(root), `baseline ${sha}\n`);
42
- writeFileSync(configPath(root), `check=${check.command}\n`);
43
- writeFileSync(intentPath(root), stamp(readTemplate('intent.md'), title, check.command));
44
- writeFileSync(buildLogPath(root), stamp(readTemplate('build-log.md'), title, check.command));
45
- excludeSidecar(root);
46
- if (check.warn) {
47
- process.stderr.write(`plumbbob: WARNING the heavy check '${check.command}' is not defined in this repo's package.json. Edit .plumbbob/config (check=...) to set the real gate before \`review\`/\`done\`.\n`);
54
+ writeFileSync(settingsPath(root), `${JSON.stringify({ auto: false }, null, 2)}\n`);
55
+ // D26: `--local` keeps today's fully-untracked flat layout (whole `.plumbbob/`
56
+ // excluded); the default plants a tracked, PR-riding `builds/<slug>/` folder
57
+ // (D26) and points the per-worktree cursor at it (D28), excluding only control
58
+ // files (D17). The slug is date-prefixed when derived (see datedSlug) and
59
+ // validated unique above — the CLI refuses, never suffixes (D38).
60
+ let intentLocation;
61
+ if (local) {
62
+ writeFileSync(checkpointsPath(root), `baseline ${sha}\n`);
63
+ writeFileSync(intentPath(root), stamp(readTemplate('intent.md'), title, CHECK_ECHO));
64
+ writeFileSync(buildLogPath(root), stamp(readTemplate('build-log.md'), title, CHECK_ECHO));
65
+ excludeSidecar(root);
66
+ intentLocation = '.plumbbob/intent.md';
48
67
  }
49
- process.stdout.write(`plumbbob: started "${title}" — baseline ${sha.slice(0, 9)}. Frame and decide in .plumbbob/intent.md; \`build\` a step once the decisions are made.\n`);
68
+ else {
69
+ const dir = buildDir(root, slug);
70
+ mkdirSync(dir, { recursive: true });
71
+ writeFileSync(join(dir, 'checkpoints'), `baseline ${sha}\n`);
72
+ writeFileSync(join(dir, 'intent.md'), stamp(readTemplate('intent.md'), title, CHECK_ECHO));
73
+ writeFileSync(join(dir, 'build-log.md'), stamp(readTemplate('build-log.md'), title, CHECK_ECHO));
74
+ setLocalSetting(root, 'activeBuild', slug);
75
+ excludeControl(root);
76
+ intentLocation = `.plumbbob/builds/${slug}/intent.md`;
77
+ }
78
+ process.stdout.write(`plumbbob: started "${title}" — baseline ${sha.slice(0, 9)}. Frame and decide in ${intentLocation}; \`build\` a step once the decisions are made.\n`);
50
79
  return 0;
51
80
  }
52
- // D24: default the heavy check to `pnpm run check`; warn (but still record it)
53
- // when the target repo has no such script, so a non-pnpm repo gets a clear nudge.
54
- function detectCheck(root) {
55
- try {
56
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
57
- return { command: DEFAULT_CHECK, warn: pkg.scripts?.check === undefined };
58
- }
59
- catch {
60
- return { command: DEFAULT_CHECK, warn: true };
61
- }
81
+ // Derived slugs carry a `YYYY-MM-DD-` prefix (local time) so `builds/` sorts
82
+ // chronologically under `listBuilds`' plain lexical sort ordering by
83
+ // construction, not by titling convention. An explicit `--slug` stays verbatim
84
+ // (D38: the CLI never rewrites what the caller chose). An untitleable title
85
+ // yields `''` so the empty-slug guard fires instead of minting a date-only slug.
86
+ function datedSlug(title) {
87
+ const base = slugify(title);
88
+ if (base.length === 0)
89
+ return '';
90
+ const now = new Date();
91
+ const pad = (n) => String(n).padStart(2, '0');
92
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${base}`;
93
+ }
94
+ // Read the value that follows a `--flag` in argv (e.g. `--slug my-build`), or
95
+ // undefined when the flag is absent or trails with no value.
96
+ function flagValue(args, flag) {
97
+ const i = args.indexOf(flag);
98
+ return i >= 0 ? args[i + 1] : undefined;
62
99
  }
63
100
  function readTemplate(name) {
64
101
  return readFileSync(fileURLToPath(new URL(`../../templates/${name}`, import.meta.url)), 'utf8');
@@ -3,7 +3,7 @@
3
3
  // behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { findRepoRoot } from "../lib/git.js";
6
- import { buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, stepPath } from "../lib/sidecar.js";
6
+ import { buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
7
7
  import { formatOrientation, orient } from "../lib/orient.js";
8
8
  function readOr(path) {
9
9
  try {
@@ -13,19 +13,30 @@ function readOr(path) {
13
13
  return '';
14
14
  }
15
15
  }
16
- export function status(cwd) {
16
+ export function status(cwd, args = []) {
17
17
  const root = findRepoRoot(cwd);
18
18
  if (root === null || !hasSession(root)) {
19
19
  process.stdout.write('NO ACTIVE SESSION\n');
20
20
  return 0;
21
21
  }
22
- const inFlightRaw = readOr(stepPath(root)).trim();
22
+ // A session with builds but no resolvable cursor (finish cleared it, or the repo
23
+ // holds several builds and none is active) has no single dashboard to show, so
24
+ // list the builds and point at `use` instead of rendering a broken, empty one.
25
+ const { build: slug } = resolveBuild(root, args);
26
+ if (slug === null) {
27
+ const builds = listBuilds(root);
28
+ if (builds.length > 0) {
29
+ process.stdout.write(`NO ACTIVE BUILD — pick one with \`plumbbob use <slug>\`:\n${builds.map((b) => ` ${b}`).join('\n')}\n`);
30
+ return 0;
31
+ }
32
+ }
33
+ const inFlightRaw = readOr(stepPath(root, slug)).trim();
23
34
  const orientation = orient({
24
- intent: readOr(intentPath(root)),
25
- buildLog: readOr(buildLogPath(root)),
26
- checkpoints: readOr(checkpointsPath(root)),
35
+ intent: readOr(intentPath(root, slug)),
36
+ buildLog: readOr(buildLogPath(root, slug)),
37
+ checkpoints: readOr(checkpointsPath(root, slug)),
27
38
  inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
28
- spiking: inSpike(root),
39
+ spiking: inSpike(root, slug),
29
40
  });
30
41
  process.stdout.write(`${formatOrientation(orientation)}\n`);
31
42
  return 0;
@@ -0,0 +1,44 @@
1
+ // `plumbbob use <slug>` (D30) — re-point the per-worktree cursor at an existing
2
+ // build and resume it. Both switching and resuming are the same one word (Q10,
3
+ // `nvm use`-shaped): the cursor is a single scalar key in settings.local.json, so
4
+ // pointing it elsewhere IS the switch and one-active-per-worktree holds by
5
+ // construction (D28). It validates the target folder exists, and warns — but
6
+ // allows — leaving a build with a step in flight: that surviving in-flight state
7
+ // is D26's whole payoff, resumed the next time you `use` that build.
8
+ import { existsSync } from 'node:fs';
9
+ import { findRepoRoot } from "../lib/git.js";
10
+ import { activeBuild, hasSession, intentPath, listBuilds, stepPath } from "../lib/sidecar.js";
11
+ import { setLocalSetting } from "../lib/settings.js";
12
+ export function use(cwd, args) {
13
+ const root = findRepoRoot(cwd);
14
+ if (root === null || !hasSession(root)) {
15
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
16
+ return 1;
17
+ }
18
+ const builds = listBuilds(root);
19
+ const target = args.find((a) => !a.startsWith('--'));
20
+ if (target === undefined || target.length === 0) {
21
+ process.stderr.write(`plumbbob: use needs a build slug.${buildsHint(builds)}\n`);
22
+ return 1;
23
+ }
24
+ // Validate by the folder's intent.md, not the dir alone: an empty `builds/<slug>/`
25
+ // is not a resumable build, and the flat `--local` layout has no builds/ at all.
26
+ if (!existsSync(intentPath(root, target))) {
27
+ process.stderr.write(`plumbbob: no build named "${target}" in .plumbbob/builds/.${buildsHint(builds)}\n`);
28
+ return 1;
29
+ }
30
+ const leaving = activeBuild(root);
31
+ if (leaving !== null && leaving !== target && existsSync(stepPath(root, leaving))) {
32
+ process.stderr.write(`plumbbob: note — build "${leaving}" has a step in flight; its in-flight state is preserved ` +
33
+ `and resumes when you \`use ${leaving}\` again.\n`);
34
+ }
35
+ setLocalSetting(root, 'activeBuild', target);
36
+ const resuming = existsSync(stepPath(root, target)) ? ' (a step is in flight — `status` shows where)' : '';
37
+ process.stdout.write(`plumbbob: now on build "${target}"${resuming}. \`status\` to orient.\n`);
38
+ return 0;
39
+ }
40
+ // A trailing " Builds: a, b." hint when any exist, so a bad or missing slug points
41
+ // the user straight at the valid choices.
42
+ function buildsHint(builds) {
43
+ return builds.length > 0 ? ` Builds: ${builds.join(', ')}.` : '';
44
+ }
@@ -1,14 +1,20 @@
1
1
  #!/bin/sh
2
2
  # post-edit.sh — light feedback (D25). PostToolUse, non-blocking, ALWAYS exits 0.
3
3
  # Runs file-scoped oxlint + ast-grep on the changed file and injects any failures
4
- # into the model's context via additionalContext (verified API, D3). No-ops when
4
+ # into the model's context via additionalContext (verified API, D25). No-ops when
5
5
  # the tools are absent or there is no session. tsc is deferred to the heavy tier
6
6
  # (D25): it has no true single-file mode and would tax every keystroke.
7
7
 
8
+ # The repo root is the nearest ancestor whose .plumbbob/settings.local.json carries
9
+ # an `activeBuild` cursor — the per-worktree signal that a tracked build is live
10
+ # here (D28). Grep the same way the file_path read below does: a plain pattern
11
+ # match, no JSON parse. The cursor replaces the old .plumbbob/STATE probe as the
12
+ # "a build is active here" root marker.
8
13
  find_root() {
9
14
  d=$(pwd -P)
10
15
  while [ -n "$d" ]; do
11
- if [ -f "$d/.plumbbob/STATE" ]; then
16
+ if [ -f "$d/.plumbbob/settings.local.json" ] &&
17
+ grep -q '"activeBuild"' "$d/.plumbbob/settings.local.json" 2>/dev/null; then
12
18
  printf '%s' "$d"
13
19
  return 0
14
20
  fi
@@ -18,7 +24,7 @@ find_root() {
18
24
  return 1
19
25
  }
20
26
 
21
- root=$(find_root) || exit 0 # no session: no feedback
27
+ root=$(find_root) || exit 0 # no active build here: no feedback
22
28
 
23
29
  input=$(cat)
24
30
  path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' 2>/dev/null)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.4.14",
4
- "description": "Attention-first build process: 11 skills + a CLI that keep you the decider — guidance, not enforcement.",
3
+ "version": "0.5.4",
4
+ "description": "Attention-first build process: 12 skills + a CLI that keep you the decider — guidance, not enforcement.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "claude",
@@ -43,22 +43,23 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@ast-grep/cli": "^0.43.0",
46
+ "@stryker-mutator/core": "^9.6.1",
47
+ "@stryker-mutator/vitest-runner": "^9.6.1",
46
48
  "@types/node": "^24.0.0",
47
- "knip": "^6.16.1",
48
- "markdownlint-cli": "^0.48.0",
49
+ "@vitest/coverage-v8": "^4.1.9",
50
+ "fallow": "^2.103.0",
51
+ "markdownlint-cli2": "^0.22.1",
49
52
  "oxlint": "^1.69.0",
50
53
  "typescript": "^6.0.3",
51
54
  "vitest": "^4.1.8"
52
55
  },
56
+ "dependencies": {
57
+ "checkride": "0.1.6"
58
+ },
53
59
  "scripts": {
54
60
  "clean": "rm -rf dist",
55
61
  "build": "tsc -p tsconfig.build.json",
56
- "check": "pnpm run check:tsc && pnpm run check:oxlint && pnpm run check:astgrep && pnpm run check:test && pnpm run check:knip && pnpm run check:md",
57
- "check:tsc": "tsc --noEmit",
58
- "check:oxlint": "oxlint",
59
- "check:astgrep": "ast-grep scan",
60
- "check:test": "vitest run",
61
- "check:knip": "knip",
62
- "check:md": "markdownlint --config .markdownlint.jsonc \"**/*.md\" --ignore node_modules --ignore research"
62
+ "check": "checkride",
63
+ "test": "vitest run"
63
64
  }
64
65
  }
@@ -38,11 +38,17 @@ at the pause for your approval. **Re-firing `/pb-build` is itself the clock tick
38
38
  touching more than the seam, that is scope drift: surface it to the human rather
39
39
  than sprawling.
40
40
  5. **Verify, through to the pause.** Run the verify tick exactly as `/pb-verify`
41
- does: `plumbbob check` self-review the diff against the done-when, the
41
+ does: `plumbbob check` (on red, read `.check/summary.json` and the failing slot's
42
+ raw output under `.check/` for the actual diagnostics; while iterating on a fix,
43
+ narrow the loop with `plumbbob check --bail --only <slots>` — the checkpoint gate
44
+ still runs everything) → self-review the diff against the done-when, the
42
45
  Decisions, and the Constraints (a single structured read, D16) → validate → **PAUSE
43
46
  for the human's approval** → only on approval, checkpoint with
44
- `plumbbob checkpoint` (which also appends this step to the build-log's `## Log` the
45
- history writes itself at each checkpoint, so you don't hand-log it). Do **not** bump
47
+ `plumbbob checkpoint <n> --body <<'BODY' BODY` a commit body **proportional to the
48
+ step** (a line for a trivial change, a short paragraph for a meatier one; no TIL scan,
49
+ no separate commit skill). The CLI owns the subject and appends this step to the
50
+ build-log's `## Log`, so the history writes itself — you only supply the body (or omit
51
+ `--body` for the deterministic done-when + seam + diffstat fallback). Do **not** bump
46
52
  the version or changelog — that is the human's `/version` call.
47
53
 
48
54
  ## `--auto` — let the agent be the clock (opt-in)
@@ -1,22 +1,25 @@
1
1
  ---
2
- name: pb-wrap
3
- description: Wrap up the build — write the report (what shipped, decisions, parked/harvested items, deferred tangents), then safely archive intent + build-log + report before clearing for a fresh goal. Archive-then-clear, never destroy. Report by default, no gate.
2
+ name: pb-finish
3
+ description: Finish the build — write the report (what shipped, decisions, parked/harvested items, deferred tangents), then make the final commit that closes the session. The build folder rides the branch into the PR no separate archive. Report by default, no gate.
4
4
  disable-model-invocation: true
5
5
  model: opus
6
- allowed-tools: Read, Write, Bash(plumbbob status:*), Bash(plumbbob wrap:*)
6
+ allowed-tools: Read, Write, Bash(plumbbob status:*), Bash(plumbbob finish:*)
7
7
  ---
8
8
 
9
- # PlumbBob — wrap the build (the close-out)
9
+ # PlumbBob — finish the build (the close-out)
10
10
 
11
11
  Current session state (injected when this skill runs): !`plumbbob status 2>/dev/null || echo "plumbbob CLI not found - install the dep and re-run: npm i -g plumbbob && plumbbob init"`
12
12
 
13
- `/pb-wrap` ends the build: it captures what happened, archives it, and clears the
14
- sidecar for the next goal. **Report by default** (D9) — no refuse-without-report gate,
15
- and no separate docs phase.
13
+ `/pb-finish` ends the build: it captures what happened, then makes the final commit
14
+ that closes the session. **Report by default** (D9) — no refuse-without-report gate,
15
+ and no separate docs phase. The build folder is tracked (D26/D29): it merges with the
16
+ branch and shows up in the PR, so there is nothing to archive — the folder *is* the
17
+ record.
16
18
 
17
19
  ## What this skill does, in order
18
20
 
19
- 1. **Write the report** to `.plumbbob/report.md`, from `intent.md` + `build-log.md`.
21
+ 1. **Write the report** to the active build's `report.md`
22
+ (`.plumbbob/builds/<slug>/report.md`), from `intent.md` + `build-log.md`.
20
23
  The build-log's `## Log` is already the chronological history — `plumbbob checkpoint`
21
24
  wrote a dated line for every step as it landed. **Read it as the spine; do not
22
25
  re-narrate it.** The report adds only what the log does not already carry:
@@ -26,10 +29,11 @@ and no separate docs phase.
26
29
  - **Final status** — done or partial, and what is left.
27
30
  - **Deferred tangents** — the harvested items that become future work.
28
31
  This is the "yeah, I did that" artifact. Write it by default; the human may edit it.
29
- 2. **Archive & clear.** Run `plumbbob wrap`, which appends the checkpoint SHAs
30
- to the report, archives intent + build-log + report to
31
- `.plumbbob/archive/<date>-<slug>/`, and clears the sidecar (STATE last). Git is not
32
- touched.
32
+ 2. **Finish.** Run `plumbbob finish`, which appends the checkpoint SHAs to the
33
+ report and makes the final commit subject `plumbbob: finish <title>` (D34),
34
+ with an optional proportional body via `--body` (the D34 stdin heredoc) then
35
+ clears the control state (markers, the `activeBuild` cursor, STATE last). The
36
+ build folder stays in place, committed, and rides the branch into the PR.
33
37
  3. **Point at the next goal** — `/pb-plan` to frame the next one.
34
38
 
35
39
  ## The hard contracts
@@ -37,9 +41,10 @@ and no separate docs phase.
37
41
  - **Report by default, never a gate.** Always offer the report; never wall the exit. A
38
42
  bug fix's report can be three lines — size it to the work.
39
43
  - **The log is the history; the report is the synthesis.** `checkpoint` already recorded
40
- what shipped, step by step — wrap applies the *unique additions* (the why, the deferred
44
+ what shipped, step by step — finish applies the *unique additions* (the why, the deferred
41
45
  tangents, the final status), it does not rewrite the timeline.
42
- - **Archive-then-clear, never destroy** (C4). The archive is the record; the active
43
- files only clear once they are safely copied.
46
+ - **The folder is the archive, never destroy** (C4/D29). `finish` keeps intent +
47
+ build-log + report in `builds/<slug>/` and commits them; nothing is copied out and
48
+ nothing is deleted but the untracked control markers.
44
49
  - **No version bump, no docs phase.** Updating real docs is a separate, explicit ask;
45
50
  cutting a release is the human's `/version`.
@@ -4,7 +4,7 @@ description: "Frame a fresh goal and author the whole plan — Frame, Decisions,
4
4
  argument-hint: "[spec-path | intent]"
5
5
  disable-model-invocation: true
6
6
  model: opus
7
- allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob start:*)
7
+ allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob start:*), Bash(plumbbob checkpoint:*)
8
8
  ---
9
9
 
10
10
  # PlumbBob — plan a goal (the whole-goal move)
@@ -60,7 +60,14 @@ an agent can follow with `/pb-build`. The argument only seeds how you get there.
60
60
  paths it touches). Later steps may be fuzzier than the first — that's fine; they get
61
61
  sharpened just-in-time when you reach them with `/pb-step`. Keep each small enough to
62
62
  verify in one review pass.
63
- 5. **Offer to stress-test it.** Suggest `/pb-refine` to attack the frame for holes (or
63
+ 5. **Commit the plan.** Once the human approves the frame and steps, run
64
+ `plumbbob checkpoint --plan` to commit the scaffold on its own — subject
65
+ `plumbbob: plan — <title>`, only `.plumbbob/builds/<slug>/`, a `plan <sha>` line in
66
+ `checkpoints` (D36). This keeps the first step's diff clean, so history reads
67
+ baseline → plan → steps. Pass a proportional `--body` (the single-quoted stdin
68
+ heredoc) when the rationale is worth carrying; skip it for a small plan. Do this
69
+ only on the human's approval — the plan is their convergence.
70
+ 6. **Offer to stress-test it.** Suggest `/pb-refine` to attack the frame for holes (or
64
71
  to repair the plan as it drifts). Optional, the human's call.
65
72
 
66
73
  ## The interview (mode 1)
@@ -16,9 +16,14 @@ this skill verifies it the same way: **it reads the diff, not the author** (D3).
16
16
 
17
17
  ## What this skill does, in order
18
18
 
19
- 1. **Check.** Run `plumbbob check` (the heavy gate). If it comes back
20
- **red**, stop here: report what failed and do **not** pause for approval — there
21
- is nothing to approve yet. The human fixes it and re-invokes.
19
+ 1. **Check.** Run `plumbbob check` (the heavy gate checkride unless the repo
20
+ configures a `check` override, D32). If it comes back **red**, stop here: the gate
21
+ names the failing slots and where each tool's raw output landed — read
22
+ `.check/summary.json`, then the failing slot's own file (`.check/<slot>.json` or
23
+ `.check/<slot>.stdout.txt`) for the actual diagnostics instead of scraping
24
+ scrollback. Report what failed and do **not** pause for approval — there is
25
+ nothing to approve yet. The human fixes it and re-invokes. (Exit 2 means the gate
26
+ itself broke — a harness problem to surface, not a code failure.)
22
27
  2. **Self-review** *(a single structured read, D16)*. Read `git diff` and
23
28
  `.plumbbob/intent.md`, then in one pass check the diff against:
24
29
  - the current step's **done-when** criterion — is it actually met?
@@ -29,10 +34,22 @@ this skill verifies it the same way: **it reads the diff, not the author** (D3).
29
34
  4. **PAUSE.** Present the check result, the self-review, and the validation, then
30
35
  **stop and wait for the human's explicit approval.** This is the convergence beat;
31
36
  the human is the clock. Never checkpoint without it.
32
- 5. **Checkpoint** *(only after approval)*. Commit the work the human's
33
- commit-with-TIL skill for a rich message, or let `checkpoint` make the WIP commit
34
- then run `plumbbob checkpoint` to record the SHA, flip the step to done, append the
35
- step to the build-log's `## Log`, and return to DESIGN. Do **not** bump the version or
37
+ 5. **Checkpoint** *(only after approval)*. Run `plumbbob checkpoint`: it makes the WIP
38
+ commit, records the SHA, flips the step to done, appends the step to the build-log's
39
+ `## Log`, and returns to DESIGN. The CLI owns the commit **subject**
40
+ (`plumbbob: step N <title>`); you own the **body**. Compose a body *proportional to
41
+ the step* — a one-liner for a trivial change, a short paragraph on the what/why for a
42
+ meatier one — and pass it on stdin:
43
+
44
+ ```bash
45
+ plumbbob checkpoint <n> --body <<'BODY'
46
+ <your proportional body — what changed and why, no ceremony>
47
+ BODY
48
+ ```
49
+
50
+ Do **not** run a TIL scan or reach for a separate commit skill — the body is yours to
51
+ write in one breath. Omit `--body` entirely and the CLI writes a deterministic body
52
+ (done-when + seam + diffstat) on its own. Either way, do **not** bump the version or
36
53
  touch the changelog — that is the human's `/version` call.
37
54
 
38
55
  ## The hard contracts
@@ -6,7 +6,7 @@ step boundaries. The antidote to "my plan got lost in the noise."
6
6
  Park list : where ideas go so you do not chase them. CAPTURE, never act inline.
7
7
  Harvest : the boundary ritual that keeps you on one branch.
8
8
  Log : the build's history. `plumbbob checkpoint` appends a line per step as it
9
- lands; feeds the /pb-wrap report, then gets archived.
9
+ lands; feeds the /pb-finish report, which rides the branch into the PR.
10
10
  -->
11
11
 
12
12
  # Build log — {{TITLE}}
@@ -52,5 +52,5 @@ Harvest results this boundary:
52
52
  every time a step lands — via `/pb-build` or `/pb-verify` — so this
53
53
  fills in as you go, not at the end. Add your own decision/event lines too: this is what
54
54
  you point at to say "I did that — the LLM helped, but those were my calls."
55
- `/pb-wrap` reads this for the report; `plumbbob wrap` archives it under
56
- `.plumbbob/archive/`.)*
55
+ `/pb-finish` reads this for the report; `plumbbob finish` commits it with the build
56
+ folder, so it rides the branch into the PR.)*
@@ -46,7 +46,7 @@ holes `/pb-refine` surfaces, and as blockers fold in during BUILD.)*
46
46
 
47
47
  *(The build plan. `/pb-plan` authors the **whole list up front** — each step a small,
48
48
  verifiable increment with its own **done-when** and **seam** (the paths it will touch,
49
- which `/pb-build` records in `.plumbbob/SEAM` for orientation — awareness, not a lock).
49
+ which `/pb-build` records in the build folder's `SEAM` for orientation — awareness, not a lock).
50
50
  Then drive `/pb-build` until done. Later steps may be fuzzier than the first;
51
51
  sharpen the next one just-in-time with `/pb-step` (empty input auto-syncs it), and use
52
52
  `/pb-refine` to repair the whole plan when a blocker rewrites it.)*
@@ -1,72 +0,0 @@
1
- // The finish-phase archive helper (D20: archive is local-only markdown).
2
- // `finish` copies the three active files into .plumbbob/archive/<date>-<slug>/
3
- // before clearing the actives — "archive-then-clear, never destroy" (C4).
4
- // Functional/procedural, node builtins only (C1/C2).
5
- import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
6
- import { join } from 'node:path';
7
- import { sidecarDir, intentPath, buildLogPath } from "./sidecar.js";
8
- // report.md lives beside intent.md / build-log.md in the sidecar. It is written by
9
- // /plumbbob-report and is the gate `finish` refuses without (D19). Derived from
10
- // the exported sidecarDir so this module needs nothing new from sidecar.ts.
11
- export function reportPath(root) {
12
- return join(sidecarDir(root), 'report.md');
13
- }
14
- function archiveRoot(root) {
15
- return join(sidecarDir(root), 'archive');
16
- }
17
- // A filesystem-safe slug from arbitrary title text: lowercased, runs of
18
- // non-alphanumerics collapsed to a single hyphen, ends trimmed. Empty → `session`.
19
- function slugify(title) {
20
- const slug = title
21
- .toLowerCase()
22
- .replace(/[^a-z0-9]+/g, '-')
23
- .replace(/^-+|-+$/g, '');
24
- return slug.length === 0 ? 'session' : slug;
25
- }
26
- // The session title is intent.md's first `# ` heading (start stamps `# <title>`).
27
- function sessionTitle(root) {
28
- let content = '';
29
- try {
30
- content = readFileSync(intentPath(root), 'utf8');
31
- }
32
- catch {
33
- return 'session';
34
- }
35
- for (const line of content.split('\n')) {
36
- const m = /^#\s+(.+)$/.exec(line);
37
- if (m) {
38
- return (m[1] ?? '').trim();
39
- }
40
- }
41
- return 'session';
42
- }
43
- // Today as YYYY-MM-DD (UTC); the archive directory is <date>-<slug>.
44
- function today() {
45
- return new Date().toISOString().slice(0, 10);
46
- }
47
- // Choose <date>-<slug>, disambiguating with -2, -3, … if a same-day, same-slug
48
- // session was already archived — so a second session lands ALONGSIDE the first,
49
- // never on top of it.
50
- function uniqueArchiveDir(root, base) {
51
- let candidate = join(archiveRoot(root), base);
52
- let n = 2;
53
- while (existsSync(candidate)) {
54
- candidate = join(archiveRoot(root), `${base}-${n}`);
55
- n += 1;
56
- }
57
- return candidate;
58
- }
59
- // Copy intent + build-log + report into archive/<date>-<slug>/ and return the
60
- // directory created. Intent and build-log always exist in an active session; the
61
- // report is copied only when present — `wrap` does not gate on it (D9), so a
62
- // close-out without a report still archives the rest.
63
- export function archiveSession(root) {
64
- const dir = uniqueArchiveDir(root, `${today()}-${slugify(sessionTitle(root))}`);
65
- mkdirSync(dir, { recursive: true });
66
- copyFileSync(intentPath(root), join(dir, 'intent.md'));
67
- copyFileSync(buildLogPath(root), join(dir, 'build-log.md'));
68
- if (existsSync(reportPath(root))) {
69
- copyFileSync(reportPath(root), join(dir, 'report.md'));
70
- }
71
- return dir;
72
- }
@@ -1,55 +0,0 @@
1
- // `plumbbob wrap` (D9) — the close-out: one verb does the whole thing.
2
- // It archives intent + build-log + report
3
- // (the `/plumbbob:pb-wrap` skill writes the report by default) under .plumbbob/archive/,
4
- // clears the active files, and deletes the control state (STATE last). There is
5
- // NO refuse-without-report gate — guidance offers the artifact, it
6
- // does not wall the exit. Archive-then-clear, never destroy (C4); git untouched (C5).
7
- import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
8
- import { join, relative } from 'node:path';
9
- import { findRepoRoot } from "../lib/git.js";
10
- import { buildLogPath, checkpointsPath, hasSession, intentPath, seamPath, sidecarDir, spikePath, stepPath } from "../lib/sidecar.js";
11
- import { archiveSession, reportPath } from "../lib/archive.js";
12
- export function wrap(cwd) {
13
- const root = findRepoRoot(cwd);
14
- if (root === null || !hasSession(root)) {
15
- process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
16
- return 1;
17
- }
18
- if (existsSync(reportPath(root))) {
19
- appendCheckpointShas(root);
20
- }
21
- else {
22
- process.stderr.write('plumbbob: note — no report.md found; archiving intent + build-log without one ' +
23
- '(/plumbbob:pb-wrap normally writes the report first). No gate (D9).\n');
24
- }
25
- const archived = archiveSession(root);
26
- // Clear the active files — now safely archived — then the control state, STATE
27
- // last so "no session" flips exactly at the end.
28
- rmSync(intentPath(root), { force: true });
29
- rmSync(buildLogPath(root), { force: true });
30
- rmSync(reportPath(root), { force: true });
31
- rmSync(seamPath(root), { force: true });
32
- rmSync(stepPath(root), { force: true });
33
- rmSync(spikePath(root), { force: true });
34
- rmSync(join(sidecarDir(root), 'STATE'), { force: true });
35
- process.stdout.write(`plumbbob: wrap — archived to ${relative(root, archived)}. Sidecar cleared. ` +
36
- 'Run `/plumbbob:pb-plan` (or `plumbbob start "<title>"`) to frame the next goal.\n');
37
- return 0;
38
- }
39
- // Append the recorded checkpoints (baseline + each `step n <sha>`) to the report so
40
- // the archived report lists the SHAs.
41
- function appendCheckpointShas(root) {
42
- let raw = '';
43
- try {
44
- raw = readFileSync(checkpointsPath(root), 'utf8');
45
- }
46
- catch {
47
- raw = '';
48
- }
49
- const lines = raw
50
- .split('\n')
51
- .map((l) => l.trim())
52
- .filter((l) => l.length > 0)
53
- .map((l) => `- ${l}`);
54
- appendFileSync(reportPath(root), ['', '## Checkpoints', '', ...lines, ''].join('\n'));
55
- }