plumbbob 0.8.11 → 0.8.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.8.11",
4
+ "version": "0.8.13",
5
5
  "description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
package/dist/cli-core.js CHANGED
@@ -29,7 +29,7 @@ const VERBS = [
29
29
  { name: 'checkpoint', summary: 'checkpoint [<n>]: gate on green, commit/record SHA, mark step done (executor-agnostic)' },
30
30
  { name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA' },
31
31
  { name: 'park', summary: 'park "<text>": append a raw line to the park list' },
32
- { name: 'spike', summary: 'spike "<slug>" | spike done: throwaway worktree experiment' },
32
+ { name: 'spike', summary: 'spike "<slug>" | spike report "<slug>" | spike done: worktree experiment + report' },
33
33
  { name: 'use', summary: 'use <slug>: re-point the active-build cursor and resume that build' },
34
34
  { name: 'finish', summary: 'close-out: report + final commit (no gate), clear the control state, close the session' },
35
35
  { name: 'init', summary: 'link plumbbob into Claude Code as an in-place plugin (~/.claude/skills/plumbbob); --uninstall to undo' },
@@ -1,8 +1,10 @@
1
1
  // Mutations to the active build-log.md — the human-facing ledger. `park` appends to
2
2
  // its Park list; `checkpoint` appends to its Log so the build's history accrues
3
3
  // line-by-line *as it happens* rather than being reconstructed at finish (D-history).
4
- // Pure string transforms (no fs, no classes — C1/C2); callers own the IO so these
5
- // stay trivially testable.
4
+ // `build`/`checkpoint`/`revert` keep its `## Steps` mirror and `**Current step:**`
5
+ // line in sync with intent.md (D69), so the ledger's top half tracks reality without
6
+ // a model turn. Pure string transforms (no fs, no classes — C1/C2); callers own the
7
+ // IO so these stay trivially testable.
6
8
  // Append `line` after the last non-blank line of the `## <heading>` section — i.e.
7
9
  // just before the next `## ` heading or EOF. Returns null when the section is absent
8
10
  // so the caller can report rather than silently corrupt the doc.
@@ -25,6 +27,51 @@ export function appendToSection(content, heading, line) {
25
27
  lines.splice(insertAt, 0, line);
26
28
  return lines.join('\n');
27
29
  }
30
+ // Replace the `**Current step:**` line with `**Current step:** <label>` — `build`
31
+ // sets it to `<n> — <title>` when a step opens; `checkpoint`/`revert` reset it to
32
+ // `none (at the boundary)`. Returns null when the line is absent so the caller skips
33
+ // rather than corrupting the doc (C3).
34
+ export function setCurrentStep(content, label) {
35
+ const lines = content.split('\n');
36
+ const idx = lines.findIndex((l) => /^\*\*Current step:\*\*/.test(l.trim()));
37
+ if (idx === -1) {
38
+ return null;
39
+ }
40
+ lines[idx] = `**Current step:** ${label}`;
41
+ return lines.join('\n');
42
+ }
43
+ // Regenerate the `## Steps` mirror from intent.md's parsed steps — one
44
+ // `- ☑/☐ N. <title>` line each, in order. Only the mirror's own `- ☑/☐` list lines
45
+ // are owned; the italic instructions paragraph (and any other prose) is preserved,
46
+ // and exactly one blank line separates it from the list. Returns null when the
47
+ // section is absent (C3). With no steps the mirror renders empty — the plan is not
48
+ // authored yet — rather than inventing a placeholder.
49
+ export function syncStepsSection(content, steps) {
50
+ const lines = content.split('\n');
51
+ const headingIdx = lines.findIndex((l) => l.trim() === '## Steps');
52
+ if (headingIdx === -1) {
53
+ return null;
54
+ }
55
+ let end = lines.findIndex((l, i) => i > headingIdx && l.startsWith('## '));
56
+ if (end === -1) {
57
+ end = lines.length;
58
+ }
59
+ // The section body minus the mirror list lines, with trailing blanks trimmed, is
60
+ // the prose to preserve (the instructions paragraph). Rebuild: prose, one blank,
61
+ // the fresh list, one blank before the next heading.
62
+ const prose = lines.slice(headingIdx + 1, end).filter((l) => !isMirrorLine(l));
63
+ while (prose.length > 0 && (prose.at(-1) ?? '').trim() === '') {
64
+ prose.pop();
65
+ }
66
+ const list = steps.map((s) => `- ${s.done ? '☑' : '☐'} ${s.n}. ${s.title}`);
67
+ const body = [...prose, ...(prose.length > 0 ? [''] : []), ...list, ''];
68
+ return [...lines.slice(0, headingIdx + 1), ...body, ...lines.slice(end)].join('\n');
69
+ }
70
+ // A mirror list line the CLI owns: `- ☑ …` / `- ☐ …`. The template's initial
71
+ // `- ☐ 1. <step>` placeholder matches, so the first sync replaces it in place.
72
+ function isMirrorLine(line) {
73
+ return /^-\s+[☑☐]/.test(line);
74
+ }
28
75
  // The Log line `checkpoint` writes for a landed step: dated, names the step (its
29
76
  // title when intent.md still carries it), and carries the short SHA. One line of the
30
77
  // build's history — `/pb-finish` reads these instead of re-narrating the build.
@@ -0,0 +1,34 @@
1
+ // Keep the build-log's top half — the `**Current step:**` line and the `## Steps`
2
+ // mirror — in sync with intent.md (D69). `build` sets the current-step label when a
3
+ // step opens; `checkpoint` and `revert` reset it to the boundary; all three re-render
4
+ // the mirror from intent.md's parsed steps so it tracks reality without a model turn.
5
+ // The write is best-effort (a missing or hand-edited build-log never fails a verb —
6
+ // the checkpoints ledger and intent.md stay the source of truth), matching how
7
+ // `checkpoint`'s Log append already behaves.
8
+ import { readFileSync, writeFileSync } from 'node:fs';
9
+ import { buildLogPath, intentPath } from "./sidecar.js";
10
+ import { parseSteps } from "./orient.js";
11
+ import { setCurrentStep, syncStepsSection } from "./buildlog.js";
12
+ export const AT_BOUNDARY = 'none (at the boundary)';
13
+ // The current-step label for an open step: `<n> — <title>`, or a bare `<n>` when the
14
+ // step carries no title. `build` passes this; the boundary verbs pass AT_BOUNDARY.
15
+ export function stepLabel(n, title) {
16
+ return title !== null && title.trim().length > 0 ? `${n} — ${title.trim()}` : String(n);
17
+ }
18
+ // Rewrite the active build's build-log top half: set the Current step line to `label`
19
+ // and re-render the `## Steps` mirror from intent.md. Each transform is skipped when
20
+ // its target is absent (null), and the whole thing swallows any IO error — the ledger
21
+ // must never block the verb.
22
+ export function syncBuildLogState(root, slug, label) {
23
+ try {
24
+ const path = buildLogPath(root, slug);
25
+ const steps = parseSteps(readFileSync(intentPath(root, slug), 'utf8'));
26
+ let content = readFileSync(path, 'utf8');
27
+ content = setCurrentStep(content, label) ?? content;
28
+ content = syncStepsSection(content, steps) ?? content;
29
+ writeFileSync(path, content);
30
+ }
31
+ catch {
32
+ // best-effort ledger; never fail a verb over the build-log.
33
+ }
34
+ }
@@ -0,0 +1,72 @@
1
+ // Conventional-Commit subject assembly (D68). Plumbbob's commit subjects read as
2
+ // `type(scope): description` — real feat/fix/chore types and a real parenthesised
3
+ // scope — so `git log` speaks the same grammar as the rest of the branch. The
4
+ // `plumbbob`/`step N` identifiers that used to prefix the subject (the old D34
5
+ // shape) now ride a marker line at the head of the body, which `withMarker`
6
+ // prepends — so `git log --grep plumbbob` still finds every plumbbob commit.
7
+ // Functional/procedural, node-builtins-only (C1/C2), no gate: these only shape a
8
+ // string the CLI is about to commit.
9
+ // The Conventional-Commit type vocabulary plumbbob recognises as a leading prefix
10
+ // on a step title. Anchoring on this closed set keeps a prose title like
11
+ // `Note: rewire the cache` from being misread as a `Note` type — only these words,
12
+ // immediately followed by an optional `(scope)`, an optional `!`, and a colon,
13
+ // count as an author-supplied prefix.
14
+ const TYPES = ['feat', 'fix', 'chore', 'docs', 'refactor', 'test', 'perf', 'build', 'ci', 'style', 'revert'];
15
+ const PREFIX = new RegExp(`^(${TYPES.join('|')})(?:\\(([^)]+)\\))?(!)?:\\s+(.+)$`);
16
+ // Split an optional Conventional prefix off a step title. `feat(escape-hatch): add
17
+ // the flag` → { type:'feat', scope:'escape-hatch', breaking:false, description:'add
18
+ // the flag' }. A title with no recognised prefix comes back whole as the
19
+ // description with a null type/scope — the caller then defaults the type and fills
20
+ // the scope from the build.
21
+ export function parseConventionalTitle(title) {
22
+ const m = PREFIX.exec(title.trim());
23
+ if (m === null) {
24
+ return { type: null, scope: null, breaking: false, description: title.trim() };
25
+ }
26
+ return { type: m[1] ?? null, scope: m[2] ?? null, breaking: m[3] === '!', description: (m[4] ?? '').trim() };
27
+ }
28
+ // Assemble a `type(scope)!: description` subject, omitting the `(scope)` segment
29
+ // when there is no scope (Conventional Commits allow a bare `type: description`)
30
+ // and the `!` when the change is not breaking.
31
+ export function conventionalSubject(parts) {
32
+ const scope = parts.scope ? `(${parts.scope})` : '';
33
+ const bang = parts.breaking ? '!' : '';
34
+ return `${parts.type}${scope}${bang}: ${parts.description}`;
35
+ }
36
+ // A Conventional subject from a step title, filling gaps from the build. An
37
+ // author-written prefix (`fix(parser): …`) is honoured verbatim — its type, scope,
38
+ // breaking marker, and exact wording all win. A bare prose title defaults to
39
+ // `defaultType` and the build `scope`, and gets its sentence-case opener
40
+ // de-capitalised (`Add the flag` → `add the flag`) to match Conventional style —
41
+ // while an all-caps acronym start (`API`, `OAuth`) is left alone.
42
+ export function subjectFromTitle(title, defaultType, scope) {
43
+ const parsed = parseConventionalTitle(title);
44
+ if (parsed.type !== null) {
45
+ return conventionalSubject({
46
+ type: parsed.type,
47
+ scope: parsed.scope ?? scope,
48
+ breaking: parsed.breaking,
49
+ description: parsed.description,
50
+ });
51
+ }
52
+ return conventionalSubject({ type: defaultType, scope, description: lowerFirst(parsed.description) });
53
+ }
54
+ // Prepend a marker line (`plumbbob step 1`, `plumbbob plan`, `plumbbob finish`) to
55
+ // an optional body, separated by a blank line so git keeps them as distinct
56
+ // paragraphs. Always returns at least the marker, so every plumbbob commit carries
57
+ // the greppable identifier in its body even when the prose is empty.
58
+ export function withMarker(marker, body) {
59
+ const rest = body?.trim();
60
+ return rest !== undefined && rest.length > 0 ? `${marker}\n\n${rest}` : marker;
61
+ }
62
+ // De-capitalise a sentence-case opener but leave an acronym/proper all-caps start
63
+ // (`API`, `OAuth`) intact: lowercase the first character only when it is uppercase
64
+ // and the second character is not (so `Add` → `add`, but `API` stays `API`).
65
+ function lowerFirst(text) {
66
+ const a = text[0] ?? '';
67
+ const b = text[1] ?? '';
68
+ if (a >= 'A' && a <= 'Z' && b >= 'A' && b <= 'Z') {
69
+ return text;
70
+ }
71
+ return a >= 'A' && a <= 'Z' ? a.toLowerCase() + text.slice(1) : text;
72
+ }
@@ -40,6 +40,18 @@ export function slugify(title) {
40
40
  .replace(/[^a-z0-9]+/g, '-')
41
41
  .replace(/^-+|-+$/g, '');
42
42
  }
43
+ // The Conventional-Commit scope for a build (D68): its slug with the `YYYY-MM-DD-`
44
+ // date prefix `start` prepends stripped off, so `2026-07-18-escape-hatch` yields the
45
+ // human-meaningful `escape-hatch`. Null when no build resolves (`--local`, or a slug
46
+ // that is nothing but a date) — the caller then omits the `(scope)` segment, which
47
+ // Conventional Commits permit.
48
+ export function buildScope(slug) {
49
+ if (slug === null) {
50
+ return null;
51
+ }
52
+ const stripped = slug.replace(/^\d{4}-\d{2}-\d{2}-/, '');
53
+ return stripped.length > 0 ? stripped : null;
54
+ }
43
55
  // Existing build slugs — the directory names under `builds/`, sorted. Empty when
44
56
  // `builds/` is absent (a `--local` repo, or before the first tracked `start`).
45
57
  export function listBuilds(root) {
@@ -146,6 +158,31 @@ export function buildLogPath(root, slug) {
146
158
  export function reportPath(root, slug) {
147
159
  return join(artifactDir(root, slug), 'report.md');
148
160
  }
161
+ // Spike reports (D70): `spike-NN-<slug>.md` in the build folder, beside report.md.
162
+ // One per spike — an explicit `/pb-spike` or a planned `spike:` step — so a build with
163
+ // several forks tells its decision history through the folder listing. NN is
164
+ // CLI-allocated (the human never numbers them), zero-padded for a stable sort.
165
+ // The existing spike-report filenames in the build folder, sorted. Empty when the
166
+ // folder is absent or holds none.
167
+ export function listSpikeReports(root, slug) {
168
+ try {
169
+ return readdirSync(artifactDir(root, slug))
170
+ .filter((name) => /^spike-\d\d-.+\.md$/.test(name))
171
+ .sort();
172
+ }
173
+ catch {
174
+ return [];
175
+ }
176
+ }
177
+ // The path for a NEW spike report: the next free zero-padded index (one past the
178
+ // highest existing NN, so a gap never reallocates a number) and the sanitized spike
179
+ // slug. Pure allocation — the caller writes the stamped template there.
180
+ export function nextSpikeReportPath(root, slug, spikeSlug) {
181
+ const used = listSpikeReports(root, slug).map((name) => Number(name.slice('spike-'.length, 'spike-'.length + 2)));
182
+ const next = (used.length > 0 ? Math.max(...used) : 0) + 1;
183
+ const nn = String(next).padStart(2, '0');
184
+ return join(artifactDir(root, slug), `spike-${nn}-${spikeSlug}.md`);
185
+ }
149
186
  // handoff.json is the agent-run ledger (D47): each `agent run` appends its
150
187
  // validated envelope here so a later invocation can thread earlier results into
151
188
  // the next call's `context[]` — the CLI itself is memoryless between runs, and
@@ -0,0 +1,19 @@
1
+ // Scaffold templates live at `<pkg-root>/templates/*.md` and carry `{{PLACEHOLDER}}`
2
+ // tokens the verbs fill in. `start` stamps intent.md / build-log.md; `spike` stamps
3
+ // spike-report.md. Shared here so both resolve the template dir the same way (relative
4
+ // to the compiled module URL) and substitute with one mechanism. Pure/procedural,
5
+ // node-builtins-only (C1/C2).
6
+ import { fileURLToPath } from 'node:url';
7
+ import { readFileSync } from 'node:fs';
8
+ export function readTemplate(name) {
9
+ return readFileSync(fileURLToPath(new URL(`../../templates/${name}`, import.meta.url)), 'utf8');
10
+ }
11
+ // Replace every `{{KEY}}` with its value. Generic over the placeholder set so a new
12
+ // template can add tokens (spike-report's `{{VIA}}`/`{{DATE}}`) without touching this.
13
+ export function stampTemplate(template, subs) {
14
+ let out = template;
15
+ for (const [key, value] of Object.entries(subs)) {
16
+ out = out.split(`{{${key}}}`).join(value);
17
+ }
18
+ return out;
19
+ }
@@ -6,6 +6,7 @@ import { findRepoRoot } from "../lib/git.js";
6
6
  import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTick, stepPath } from "../lib/sidecar.js";
7
7
  import { parseStepSeam } from "../lib/intent.js";
8
8
  import { parseSteps } from "../lib/orient.js";
9
+ import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
9
10
  export function build(cwd, args) {
10
11
  const root = findRepoRoot(cwd);
11
12
  if (root === null || !hasSession(root)) {
@@ -55,6 +56,10 @@ export function build(cwd, args) {
55
56
  // The wall-clock receipt starts here (research/07 Build 2b): checkpoint stamps
56
57
  // landedAt, and the pair becomes the step's duration in the finish report.
57
58
  stampStepStat(root, slug, step, 'startedAt', new Date().toISOString());
59
+ // The build-log's top half now shows this step in flight (D69): Current step +
60
+ // the ☐/☑ mirror, re-rendered from intent.md. Best-effort — never blocks the build.
61
+ const title = parseSteps(intent).find((s) => s.n === step)?.title ?? null;
62
+ syncBuildLogState(root, slug, stepLabel(step, title));
58
63
  const picked = raw === undefined ? ' (next undone)' : '';
59
64
  process.stdout.write(`plumbbob: building step ${step}${picked}. Seam (for orientation; not a lock):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
60
65
  return 0;
@@ -9,12 +9,14 @@
9
9
  // the same way.
10
10
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
12
- import { buildFolder, buildLogPath, bumpStepStat, checkpointsPath, clearHandoff, clearTick, hasSession, intentPath, readStats, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
12
+ import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearHandoff, clearTick, hasSession, intentPath, readStats, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
13
13
  import { runCheck } from "../lib/check.js";
14
14
  import { checkLatch } from "../lib/latch.js";
15
- import { markStepDone, parseSteps, parseTitle } from "../lib/orient.js";
15
+ import { markStepDone, parseSteps } from "../lib/orient.js";
16
16
  import { parseStepSeam, scopeDrift } from "../lib/intent.js";
17
+ import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
17
18
  import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
19
+ import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
18
20
  export async function checkpoint(cwd, args) {
19
21
  const root = findRepoRoot(cwd);
20
22
  if (root === null || !hasSession(root)) {
@@ -53,7 +55,7 @@ export async function checkpoint(cwd, args) {
53
55
  if (isDirty(root)) {
54
56
  stageAll(root);
55
57
  warnScopeDrift(root, step);
56
- const body = bodyArg(args) ?? fallbackBody(root, step);
58
+ const body = withMarker(`plumbbob step ${step}`, bodyArg(args) ?? fallbackBody(root, step));
57
59
  sha = commit(root, messageArg(args) ?? subjectForStep(root, step), body);
58
60
  }
59
61
  else {
@@ -63,6 +65,10 @@ export async function checkpoint(cwd, args) {
63
65
  flipIntent(root, step);
64
66
  stampStepStat(root, undefined, step, 'landedAt', new Date().toISOString());
65
67
  logCheckpoint(root, step, sha);
68
+ // The step landed: the build-log's mirror flips to ☑ and Current step returns to
69
+ // the boundary (D69), re-read from the intent.md `flipIntent` just wrote. The
70
+ // artifact-plane whitelist keeps this build-log write from ever reading as drift.
71
+ syncBuildLogState(root, activeBuild(root), AT_BOUNDARY);
66
72
  rmSync(seamPath(root), { force: true });
67
73
  rmSync(stepPath(root), { force: true });
68
74
  clearHandoff(root); // the agent-run ledger is step-scoped (D47) — clear it with the markers.
@@ -71,12 +77,12 @@ export async function checkpoint(cwd, args) {
71
77
  return 0;
72
78
  }
73
79
  // The plan-approval commit (D36): stage only the active build's artifact folder and
74
- // commit it as `plumbbob: plan — <title>`, then record a `plan <sha>` line. Giving
80
+ // commit it as `chore(<scope>): plan` (D68), then record a `plan <sha>` line. Giving
75
81
  // the plan its own commit keeps the first step's diff from absorbing the scaffold, so
76
82
  // `git log` reads baseline → plan → steps. No check gate (there is no code work to
77
83
  // verify yet), no intent flip, no step markers — the plan lives entirely in DESIGN.
78
- // An optional `--body` (stdin heredoc, D34) rides along; the folder is whitelisted
79
- // artifact plane, so there is no scope-drift to warn about.
84
+ // An optional `--body` (stdin heredoc, D68) rides after the `plumbbob plan` marker;
85
+ // the folder is whitelisted artifact plane, so there is no scope-drift to warn about.
80
86
  function checkpointPlan(root, args) {
81
87
  // The latch (D64) covers the plan commit too, keyed on the TICK that `start`
82
88
  // stamped: the plan pause is a pause. No step number — a range grant does not
@@ -87,7 +93,7 @@ function checkpointPlan(root, args) {
87
93
  return 1;
88
94
  }
89
95
  stagePath(root, buildFolder(root));
90
- const sha = commit(root, planSubject(root), bodyArg(args) ?? undefined);
96
+ const sha = commit(root, planSubject(root), withMarker('plumbbob plan', bodyArg(args) ?? undefined));
91
97
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
92
98
  // The plan landing consumes `start`'s entry stamp (D64): a later hand-built diff
93
99
  // (no `build <n>`) must find no stale TICK and stay guidance-governed.
@@ -95,17 +101,11 @@ function checkpointPlan(root, args) {
95
101
  process.stdout.write(`plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
96
102
  return 0;
97
103
  }
98
- // The plan commit's CLI-owned subject: the intent's `# <title>`, else a bare
99
- // `plumbbob: plan` when the title can't be read (mirrors `subjectForStep`'s fallback).
104
+ // The plan commit's CLI-owned subject (D68): `chore(<scope>): plan`, the scope drawn
105
+ // from the active build's slug (a bare `chore: plan` when none resolves). The
106
+ // `plumbbob plan` identifier rides the body marker, not the subject.
100
107
  function planSubject(root) {
101
- let title = null;
102
- try {
103
- title = parseTitle(readFileSync(intentPath(root), 'utf8'));
104
- }
105
- catch {
106
- title = null;
107
- }
108
- return title ? `plumbbob: plan — ${title}` : 'plumbbob: plan';
108
+ return conventionalSubject({ type: 'chore', scope: buildScope(activeBuild(root)), description: 'plan' });
109
109
  }
110
110
  // Step resolution (D3): explicit arg > in-flight STEP file > first undone step in
111
111
  // intent.md. Returns null when none can be determined. A `-m` value is a message,
@@ -209,12 +209,18 @@ function statsSuffix(root, step) {
209
209
  }
210
210
  return parts.length > 0 ? parts.join(', ') : null;
211
211
  }
212
- // The CLI-owned, deterministic commit subject: the step's title when intent.md still
213
- // carries one, else the bare `plumbbob: step N done` fallback (D34 — the CLI owns the
214
- // subject; a `-m` override or `--body` prose is a separate concern).
212
+ // The CLI-owned, deterministic commit subject (D68): a Conventional `type(scope):
213
+ // description`. The scope comes from the active build's slug; the type and wording
214
+ // come from the step's title — an author-written prefix (`fix(parser): …`) is honoured
215
+ // verbatim, a bare title defaults to `feat`. A titleless step falls back to
216
+ // `chore(<scope>): checkpoint`. The `plumbbob`/`step N` identifiers ride the body
217
+ // marker, not the subject; a `-m` override or `--body` prose is a separate concern.
215
218
  function subjectForStep(root, step) {
219
+ const scope = buildScope(activeBuild(root));
216
220
  const title = titleForStep(root, step);
217
- return title ? `plumbbob: step ${step} — ${title}` : `plumbbob: step ${step} done`;
221
+ return title !== null && title.trim().length > 0
222
+ ? subjectFromTitle(title, 'feat', scope)
223
+ : conventionalSubject({ type: 'chore', scope, description: 'checkpoint' });
218
224
  }
219
225
  function titleForStep(root, step) {
220
226
  try {
@@ -1,15 +1,16 @@
1
- // `plumbbob finish` (D9/D34) — the close-out: append the checkpoint SHAs to the
1
+ // `plumbbob finish` (D9/D68) — the close-out: append the checkpoint SHAs to the
2
2
  // report, make the final commit, and clear the control state. The build folder is
3
3
  // NOT deleted — it IS the archive now (D29): tracked, it merges with the branch and
4
4
  // shows up in the PR, so nothing is copied into a local-only `archive/` (that
5
5
  // helper retired with this step). No refuse-without-report gate — guidance offers
6
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.
7
+ // one forward commit under the Conventional `chore(<scope>): finish` subject, its
8
+ // `plumbbob finish` identifier riding the body marker (D68).
8
9
  import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
9
10
  import { join } from 'node:path';
10
11
  import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
11
- import { checkpointsPath, clearTick, hasSession, intentPath, readStats, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
12
- import { parseTitle } from "../lib/orient.js";
12
+ import { buildScope, checkpointsPath, clearTick, hasSession, readStats, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
13
+ import { conventionalSubject, withMarker } from "../lib/commitmsg.js";
13
14
  export function finish(cwd, args = []) {
14
15
  const root = findRepoRoot(cwd);
15
16
  if (root === null || !hasSession(root)) {
@@ -33,7 +34,7 @@ export function finish(cwd, args = []) {
33
34
  if (isDirty(root)) {
34
35
  stageAll(root);
35
36
  }
36
- const sha = commit(root, subject(root, slug), bodyArg(args) ?? undefined);
37
+ const sha = commit(root, subject(slug), withMarker('plumbbob finish', bodyArg(args) ?? undefined));
37
38
  // Clear the control state: the in-flight markers first, then the session sentinel
38
39
  // last (so "no session" flips exactly at the end). Deleting STATE also drops the
39
40
  // cursor (D28) — cursor and session share the one file now, so a single delete does
@@ -53,18 +54,12 @@ export function finish(cwd, args = []) {
53
54
  'Run `/pb-plan` (or `plumbbob start "<title>"`) to frame the next goal.\n');
54
55
  return 0;
55
56
  }
56
- // The CLI-owned final-commit subject (D34): `plumbbob: finish — <title>`, mirroring
57
- // the step-checkpoint format exactly so one greppable shape spans the whole history.
58
- // Falls back to a bare `plumbbob: finish` when intent.md carries no title.
59
- function subject(root, slug) {
60
- let title = null;
61
- try {
62
- title = parseTitle(readFileSync(intentPath(root, slug), 'utf8'));
63
- }
64
- catch {
65
- title = null;
66
- }
67
- return title ? `plumbbob: finish — ${title}` : 'plumbbob: finish';
57
+ // The CLI-owned final-commit subject (D68): `chore(<scope>): finish`, the scope drawn
58
+ // from the build's slug (a bare `chore: finish` when none resolves, e.g. `--local`),
59
+ // mirroring the plan/step Conventional shape so one grammar spans the whole history.
60
+ // The `plumbbob finish` identifier rides the body marker, not the subject.
61
+ function subject(slug) {
62
+ return conventionalSubject({ type: 'chore', scope: buildScope(slug), description: 'finish' });
68
63
  }
69
64
  // `--body` reads the final-commit body from stdin (the single-quoted heredoc of
70
65
  // D34), so the pb-finish skill can compose a proportional close-out message. Returns
@@ -20,6 +20,7 @@ import { fileURLToPath } from 'node:url';
20
20
  import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
21
21
  import { bumpStepStat, checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
22
22
  import { isArtifactPath, matchesSeam } from "../lib/intent.js";
23
+ import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
23
24
  export function revert(cwd, args) {
24
25
  const root = findRepoRoot(cwd);
25
26
  if (root === null || !hasSession(root)) {
@@ -66,6 +67,10 @@ export function revert(cwd, args) {
66
67
  if (inFlight !== null) {
67
68
  bumpStepStat(root, slug, inFlight, 'reverts');
68
69
  }
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.
73
+ syncBuildLogState(root, slug, AT_BOUNDARY);
69
74
  process.stdout.write(`plumbbob: reverted to ${sha.slice(0, 9)} — back at the boundary. Park lines and intent edits were preserved.\n`);
70
75
  return 0;
71
76
  }
@@ -10,11 +10,15 @@
10
10
  // shared additive read/commit helpers): worktree management is spike-local, and
11
11
  // this is the only place Plumbbob creates branches.
12
12
  import { execFileSync } from 'node:child_process';
13
- import { existsSync } from 'node:fs';
14
- import { basename, dirname, join } from 'node:path';
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { basename, dirname, join, relative } from 'node:path';
15
15
  import { findRepoRoot } from "../lib/git.js";
16
- import { hasSession, inSpike, markSpike, clearSpike, resolveBuild, stepPath } from "../lib/sidecar.js";
16
+ import { buildFolder, clearSpike, hasSession, inSpike, listSpikeReports, markSpike, nextSpikeReportPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
17
+ import { readTemplate, stampTemplate } from "../lib/templates.js";
17
18
  const DEFAULT_OPTIONS = ['a', 'b'];
19
+ // The verbatim Verdict placeholder from templates/spike-report.md — its presence means
20
+ // the spike's call was never recorded, which `spike done` nudges on (D70).
21
+ const VERDICT_PLACEHOLDER = '*(viable | not viable | partial';
18
22
  export function spike(cwd, args) {
19
23
  const root = findRepoRoot(cwd);
20
24
  if (root === null || !hasSession(root)) {
@@ -26,8 +30,49 @@ export function spike(cwd, args) {
26
30
  if (positionals[0] === 'done') {
27
31
  return spikeDone(root, buildSlug);
28
32
  }
33
+ if (positionals[0] === 'report') {
34
+ return spikeReport(root, buildSlug, positionals.slice(1));
35
+ }
29
36
  return spikeStart(root, buildSlug, positionals);
30
37
  }
38
+ // `spike report "<slug>"` (D70) — scaffold a spike report WITHOUT worktrees, for the
39
+ // spike-as-step case (a planned step titled `spike: …`, where the increment itself is
40
+ // the experiment). No boundary requirement and no SPIKE marker: a step in flight is
41
+ // exactly when this runs. Provenance is `step <n>` when a STEP is in flight, else
42
+ // `/pb-spike`.
43
+ function spikeReport(root, buildSlug, positionals) {
44
+ const slug = sanitize(positionals[0] ?? '');
45
+ if (slug.length === 0) {
46
+ process.stderr.write('plumbbob: spike report needs a slug. Try: plumbbob spike report "auth-store".\n');
47
+ return 1;
48
+ }
49
+ const inFlight = readInFlightStep(root, buildSlug);
50
+ const via = inFlight !== null ? `step ${inFlight}` : '/pb-spike';
51
+ const path = scaffoldSpikeReport(root, buildSlug, slug, via);
52
+ process.stdout.write(`plumbbob: spike report scaffolded — ${relative(root, path)}. Fill Findings and the Verdict as you go; ` +
53
+ `a recorded Verdict is what closes a spike step.\n`);
54
+ return 0;
55
+ }
56
+ // Write a fresh spike report from the template at the next free `spike-NN-<slug>.md`
57
+ // in the build folder, and return its path. Shared by the explicit `/pb-spike` open
58
+ // and the spike-as-step `spike report` — one artifact, two entry points (D70).
59
+ function scaffoldSpikeReport(root, buildSlug, spikeSlug, via) {
60
+ const path = nextSpikeReportPath(root, buildSlug, spikeSlug);
61
+ const date = new Date().toISOString().slice(0, 10);
62
+ writeFileSync(path, stampTemplate(readTemplate('spike-report.md'), { TITLE: spikeSlug, VIA: via, DATE: date }));
63
+ return path;
64
+ }
65
+ // The in-flight STEP number for the build, or null — reads the STEP marker `build`
66
+ // writes. Used to stamp a spike-as-step report's provenance.
67
+ function readInFlightStep(root, buildSlug) {
68
+ try {
69
+ const raw = readFileSync(stepPath(root, buildSlug), 'utf8').trim();
70
+ return /^\d+$/.test(raw) ? Number(raw) : null;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
31
76
  function spikeStart(root, buildSlug, positionals) {
32
77
  if (inSpike(root, buildSlug)) {
33
78
  process.stderr.write('plumbbob: already in a spike. Run `plumbbob spike done` to close it first.\n');
@@ -56,9 +101,12 @@ function spikeStart(root, buildSlug, positionals) {
56
101
  created.push(path);
57
102
  }
58
103
  markSpike(root, buildSlug);
104
+ // Scaffold the report NOW, while the worktrees live (D70): findings accrue during the
105
+ // experiment, not from memory after the teardown. Provenance names the worktrees.
106
+ const report = scaffoldSpikeReport(root, buildSlug, slug, `/pb-spike — worktrees (${options.join(', ')})`);
59
107
  process.stdout.write(`plumbbob: spiking — the main tree stays put. Experiment in the throwaway worktrees:\n${created
60
108
  .map((p) => ` ${p}`)
61
- .join('\n')}\nWhen you've decided, record the verdict in intent.md and run \`plumbbob spike done\`.\n`);
109
+ .join('\n')}\nRecord findings and the Verdict in ${relative(root, report)} as you go, then run \`plumbbob spike done\`.\n`);
62
110
  return 0;
63
111
  }
64
112
  function spikeDone(root, buildSlug) {
@@ -66,6 +114,10 @@ function spikeDone(root, buildSlug) {
66
114
  process.stderr.write('plumbbob: no active spike to close.\n');
67
115
  return 1;
68
116
  }
117
+ // Check for an unrecorded verdict BEFORE teardown (D70). The reports live in the
118
+ // build folder, not the spike worktrees, so they survive the removal — but the
119
+ // learning that fills them does not, so this is the moment to nudge.
120
+ const unfilled = spikeReportsMissingVerdict(root, buildSlug);
69
121
  for (const path of spikeWorktrees(root)) {
70
122
  git(root, ['worktree', 'remove', '--force', path]);
71
123
  }
@@ -74,10 +126,32 @@ function spikeDone(root, buildSlug) {
74
126
  git(root, ['branch', '-D', branch]);
75
127
  }
76
128
  clearSpike(root, buildSlug);
77
- process.stdout.write('plumbbob: spike closed — worktrees and branches removed, back at the boundary. ' +
78
- 'Record the verdict (which option won, and why) in intent.md before you `build`.\n');
129
+ // Guidance, not a gate (the enforce→guide pivot): a missing verdict is a nudge, and
130
+ // the spike still closes. Name the reports so the human knows where to write it.
131
+ if (unfilled.length > 0) {
132
+ process.stderr.write(`plumbbob: heads-up — no verdict recorded in ${unfilled.join(', ')}. ` +
133
+ `The worktrees are gone; capture the call there before it fades.\n`);
134
+ }
135
+ const reports = listSpikeReports(root, buildSlug);
136
+ const where = reports.length > 0 ? `the spike report${reports.length === 1 ? '' : 's'} (${reports.join(', ')})` : 'intent.md';
137
+ process.stdout.write(`plumbbob: spike closed — worktrees and branches removed, back at the boundary. ` +
138
+ `Record the verdict (which option won, and why) in ${where} before you \`build\`.\n`);
79
139
  return 0;
80
140
  }
141
+ // The spike-report filenames in the build folder whose Verdict is still the template
142
+ // placeholder — the ones `spike done` nudges on. Best-effort per file: an unreadable
143
+ // report is skipped rather than blocking the close.
144
+ function spikeReportsMissingVerdict(root, buildSlug) {
145
+ const dir = buildFolder(root, buildSlug);
146
+ return listSpikeReports(root, buildSlug).filter((name) => {
147
+ try {
148
+ return readFileSync(join(dir, name), 'utf8').includes(VERDICT_PLACEHOLDER);
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ });
154
+ }
81
155
  // Worktree paths whose checked-out branch is under spike/ — parsed from the
82
156
  // porcelain output (blank-line-separated `worktree <path>` / `branch <ref>` blocks).
83
157
  function spikeWorktrees(root) {
@@ -1,9 +1,9 @@
1
1
  // `plumbbob start "<title>"` — scaffold the sidecar, record the baseline, open
2
2
  // the session. Refuses on a dirty tree (D22), an existing session, or a non-git dir.
3
- import { fileURLToPath } from 'node:url';
4
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
5
4
  import { join } from 'node:path';
6
5
  import { detectGate } from "../lib/check.js";
6
+ import { readTemplate, stampTemplate } from "../lib/templates.js";
7
7
  import { findRepoRoot, hasCommit, headSha, isDirty } from "../lib/git.js";
8
8
  import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
9
9
  import { settingsPath } from "../lib/settings.js";
@@ -73,8 +73,8 @@ export async function start(cwd, args) {
73
73
  let intentLocation;
74
74
  if (local) {
75
75
  writeFileSync(checkpointsPath(root), `baseline ${sha}\n`);
76
- writeFileSync(intentPath(root), stamp(readTemplate('intent.md'), title, CHECK_ECHO));
77
- writeFileSync(buildLogPath(root), stamp(readTemplate('build-log.md'), title, CHECK_ECHO));
76
+ writeFileSync(intentPath(root), stampBuild(readTemplate('intent.md'), title));
77
+ writeFileSync(buildLogPath(root), stampBuild(readTemplate('build-log.md'), title));
78
78
  excludeSidecar(root);
79
79
  intentLocation = '.plumbbob/intent.md';
80
80
  }
@@ -82,8 +82,8 @@ export async function start(cwd, args) {
82
82
  const dir = buildDir(root, slug);
83
83
  mkdirSync(dir, { recursive: true });
84
84
  writeFileSync(join(dir, 'checkpoints'), `baseline ${sha}\n`);
85
- writeFileSync(join(dir, 'intent.md'), stamp(readTemplate('intent.md'), title, CHECK_ECHO));
86
- writeFileSync(join(dir, 'build-log.md'), stamp(readTemplate('build-log.md'), title, CHECK_ECHO));
85
+ writeFileSync(join(dir, 'intent.md'), stampBuild(readTemplate('intent.md'), title));
86
+ writeFileSync(join(dir, 'build-log.md'), stampBuild(readTemplate('build-log.md'), title));
87
87
  excludeControl(root);
88
88
  intentLocation = `.plumbbob/builds/${slug}/intent.md`;
89
89
  }
@@ -128,9 +128,8 @@ function flagValue(args, flag) {
128
128
  const i = args.indexOf(flag);
129
129
  return i >= 0 ? args[i + 1] : undefined;
130
130
  }
131
- function readTemplate(name) {
132
- return readFileSync(fileURLToPath(new URL(`../../templates/${name}`, import.meta.url)), 'utf8');
133
- }
134
- function stamp(template, title, check) {
135
- return template.split('{{TITLE}}').join(title).split('{{CHECK}}').join(check);
131
+ // Stamp a build template's `{{TITLE}}`/`{{CHECK}}` — the check is always the echo
132
+ // constant (absence of a `check` setting IS the default, D24/D32).
133
+ function stampBuild(template, title) {
134
+ return stampTemplate(template, { TITLE: title, CHECK: CHECK_ECHO });
136
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "description": "Attention-first build process: 12 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -3,7 +3,7 @@ name: pb-build
3
3
  description: The default engine — read the next planned step from intent, implement it (its done-when, seam, Decisions, Constraints), then verify it through to the approval pause. Swappable — build by hand/vibed/another harness and run /pb-verify instead. `--auto` self-approves and chains to done; a step range like `1-3` self-approves through step 3, then pauses.
4
4
  argument-hint: "[step-number | step-range] [--auto]"
5
5
  disable-model-invocation: true
6
- allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob build:*), Bash(plumbbob handoff:*), Bash(plumbbob check:*), Bash(plumbbob checkpoint:*), Bash(plumbbob park:*), Bash(plumbbob agent:*), Bash(git diff:*)
6
+ allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob build:*), Bash(plumbbob handoff:*), Bash(plumbbob check:*), Bash(plumbbob checkpoint:*), Bash(plumbbob park:*), Bash(plumbbob agent:*), Bash(plumbbob spike:*), Bash(git diff:*)
7
7
  ---
8
8
 
9
9
  # PlumbBob — build a step (the default engine)
@@ -36,6 +36,13 @@ differs from the model you're running as, say so before implementing — the hum
36
36
  2. **Enter the step.** Run `plumbbob build <n>` — or bare `plumbbob build` to enter the
37
37
  next undone step (records the in-flight STEP + SEAM so `/pb-status` shows the step in
38
38
  flight; the seam is awareness, not a lock).
39
+ - **Spike-as-step.** If the step's title opens with `spike:` / `Spike:`, the increment
40
+ *is* the experiment — resolving a fork the plan couldn't settle on paper. After
41
+ entering it, run `plumbbob spike report "<slug>"` (the CLI scaffolds a
42
+ `spike-NN-<slug>.md` in the build folder, stamped `via: step <n>`), then work the
43
+ experiment and record your **Findings** and **Verdict** there. A recorded verdict is
44
+ what the step's done-when should check — the report, not just the code, is the
45
+ deliverable. This is the same artifact `/pb-spike` produces for a mid-build fork (D70).
39
46
  3. **Read the plan.** Read the step's **done-when**, its **seam**, and the
40
47
  **Decisions** and **Constraints** in `intent.md`. Build to *that* — the deciding
41
48
  already happened, off the chat.
@@ -29,8 +29,9 @@ record.
29
29
  - **Deferred tangents** — the harvested items that become future work.
30
30
  This is the "yeah, I did that" artifact. Write it by default; the human may edit it.
31
31
  2. **Finish.** Run `plumbbob finish`, which appends the checkpoint SHAs to the
32
- report and makes the final commit — subject `plumbbob: finish — <title>`,
33
- with an optional proportional body via `--body` (the stdin heredoc) — then
32
+ report and makes the final commit — subject `chore(<scope>): finish`, its body
33
+ led by a `plumbbob finish` marker plus an optional proportional `--body` (the
34
+ stdin heredoc) — then
34
35
  clears the control state (the in-flight markers, then `STATE` last — one delete
35
36
  drops both the session sentinel and the active-build cursor it carries). The
36
37
  build folder stays in place, committed, and rides the branch into the PR.
@@ -74,6 +74,13 @@ an agent can follow with `/pb-build`. The argument only seeds how you get there.
74
74
  sharpened just-in-time when you reach them with `/pb-step`. Keep each small enough to
75
75
  verify in one review pass.
76
76
 
77
+ **Give the title a Conventional-Commit type where the change has one** *(optional)*:
78
+ a `<title>` that opens with `fix:`, `feat(escape-hatch):`, `chore:`, … becomes the
79
+ checkpoint's commit subject verbatim (its scope and breaking `!` honored) — this is
80
+ how each step lands a *real* `feat`/`fix`/`chore` in `git log`. Skip the prefix and
81
+ the checkpoint defaults the type to `feat` and draws the scope from the build slug, so
82
+ a plain title still yields a clean `feat(<slug>): <title>` ([D68](../../docs/decisions.md#d68)).
83
+
77
84
  **Recommend a model per step where the signal is clear** *(optional)*: the
78
85
  `- model:` sub-line names the **smallest model that can carry the step**, with the
79
86
  one-phrase why — the human buys capability only where the step needs it. E.g.
@@ -106,7 +113,8 @@ an agent can follow with `/pb-build`. The argument only seeds how you get there.
106
113
  up automatically (it lives in the build folder).
107
114
  6. **Commit the plan.** Once the human approves the frame and steps, run
108
115
  `plumbbob checkpoint --plan` to commit the scaffold on its own — subject
109
- `plumbbob: plan — <title>`, only `.plumbbob/builds/<slug>/`, a `plan <sha>` line in
116
+ `chore(<scope>): plan` (with a `plumbbob plan` body marker), only `.plumbbob/builds/<slug>/`,
117
+ a `plan <sha>` line in
110
118
  `checkpoints`. This keeps the first step's diff clean, so history reads
111
119
  baseline → plan → steps. Pass a proportional `--body` (the single-quoted stdin
112
120
  heredoc) when the rationale is worth carrying; skip it for a small plan. Do this
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: pb-spike
3
- description: Human-triggered driver for `plumbbob spike` — open a throwaway worktree experiment for a genuine fork, or tear it down with `spike done`.
4
- argument-hint: "<slug> | done"
3
+ description: Human-triggered driver for `plumbbob spike` — open a throwaway worktree experiment for a genuine fork, scaffold a spike report for a spike-as-step, or tear it down with `spike done`.
4
+ argument-hint: "<slug> | report <slug> | done"
5
5
  disable-model-invocation: true
6
6
  model: haiku
7
7
  allowed-tools: Bash(plumbbob status:*), Bash(plumbbob spike:*)
@@ -15,6 +15,15 @@ This is a **driver skill** — a chat-side trigger for the mechanical `plumbbob
15
15
 
16
16
  ## What it does
17
17
 
18
- 1. Read the spike target from the way you were invoked: a slug to open one (e.g. `/pb-spike redis-cache`), or the literal `done` to tear the current spike down (`/pb-spike done`). If neither is present, ask which and run nothing.
19
- 2. Run `plumbbob spike "<slug>"` or `plumbbob spike done` via Bash.
20
- 3. Report the verb's output verbatim — the worktree it created or removed, or any refusal.
18
+ 1. Read the spike target from the way you were invoked: a slug to open one (e.g. `/pb-spike redis-cache`), `report <slug>` to scaffold a report without worktrees (`/pb-spike report auth-store`), or the literal `done` to tear the current spike down (`/pb-spike done`). If none is present, ask which and run nothing.
19
+ 2. Run `plumbbob spike "<slug>"`, `plumbbob spike report "<slug>"`, or `plumbbob spike done` via Bash.
20
+ 3. Report the verb's output verbatim — the worktree it created or removed, the spike report it scaffolded, or any refusal. When the verb names a `spike-NN-<slug>.md` report, point the human at it; when `spike done` nudges that a verdict is unrecorded, relay that nudge verbatim.
21
+
22
+ ## The spike report (D70)
23
+
24
+ Every spike leaves a durable `spike-NN-<slug>.md` in the build folder, beside `intent.md`/`report.md`, so the fork's verdict rides the branch into the PR instead of evaporating. The CLI scaffolds it from a template and numbers it — you never create or number the file:
25
+
26
+ - **`/pb-spike <slug>`** scaffolds the report *at open*, so findings accrue while the throwaway worktrees are still live. Fill its **Findings** and **Verdict** before `/pb-spike done`.
27
+ - **`/pb-spike report <slug>`** is for a *spike-as-step* — a planned step titled `spike: …`, where the increment itself is the experiment and there are no worktrees. It stamps the report's provenance as `step <n>` when a step is in flight.
28
+
29
+ This skill stays Write-less: the CLI owns the file. Your job is to run the verb and, between runs, help the human fill the report's prose — but the edits land through the normal build flow, not this driver.
@@ -39,8 +39,11 @@ grew — but its everyday job is to sharpen, not to invent.
39
39
  a step that sharpened into rote work can drop to a smaller model; one that grew subtle
40
40
  earns a frontier one. Advisory, plain text (no backticks), never a gate.
41
41
  3. **Get the human's OK**, then write it into `## Steps` in the standard format —
42
- `N. [ ] <title> — **done when:** <criterion>` with a `- seam:` sub-line. Revise the
43
- existing step in place; only append when you are genuinely adding an increment.
42
+ `N. [ ] <title> — **done when:** <criterion>` with a `- seam:` sub-line. Give the
43
+ `<title>` an optional Conventional-Commit prefix (`fix:`, `feat(scope):`, …) when the
44
+ change has a clear type — it becomes the checkpoint's commit subject; a plain title
45
+ defaults to `feat` with the build slug as scope ([D68](../../docs/decisions.md#d68)).
46
+ Revise the existing step in place; only append when you are genuinely adding an increment.
44
47
  4. **Revise the step's harness bindings if they drifted too** *(optional)*. If the
45
48
  build carries a `harness.json` (beside `intent.md`) and the reality that moved the
46
49
  step also changed which agents it wants, sharpen that step's slot bindings
@@ -47,10 +47,11 @@ this skill verifies it the same way: **it reads the diff, not the author**.
47
47
  the human is the clock. Never checkpoint without it.
48
48
  6. **Checkpoint** *(only after approval)*. Run `plumbbob checkpoint`: it makes the WIP
49
49
  commit, records the SHA, flips the step to done, appends the step to the build-log's
50
- `## Log`, and returns to DESIGN. The CLI owns the commit **subject**
51
- (`plumbbob: step N — <title>`); you own the **body**. Compose a body *proportional to
52
- the step* — a one-liner for a trivial change, a short paragraph on the what/why for a
53
- meatier one — and pass it on stdin:
50
+ `## Log`, and returns to DESIGN. The CLI owns the commit **subject** — a Conventional
51
+ `<type>(<scope>): <description>` composed from the step title and the build slug — and
52
+ leads the **body** with a `plumbbob step N` marker; you own the rest of the **body**.
53
+ Compose a body *proportional to the step* — a one-liner for a trivial change, a short
54
+ paragraph on the what/why for a meatier one — and pass it on stdin:
54
55
 
55
56
  ```bash
56
57
  plumbbob checkpoint <n> --body <<'BODY'
@@ -2,7 +2,9 @@
2
2
  build-log.md — your live ledger for execution. Append constantly; reorganize at
3
3
  step boundaries. The antidote to "my plan got lost in the noise."
4
4
 
5
- Steps : where you are. One step in flight at a time.
5
+ Steps : where you are. One step in flight at a time. CLI-maintained: `build`,
6
+ `checkpoint`, and `revert` keep this mirror and the Current step line
7
+ in sync with intent.md — you never hand-edit them.
6
8
  Park list : where ideas go so you do not chase them. CAPTURE, never act inline.
7
9
  Harvest : the boundary ritual that keeps you on one branch.
8
10
  Log : the build's history. `plumbbob checkpoint` appends a line per step as it
@@ -16,9 +18,10 @@ step boundaries. The antidote to "my plan got lost in the noise."
16
18
 
17
19
  ## Steps
18
20
 
19
- *(Mirror of intent.md's Steps, with live status. Only ONE step is in flight. A step
20
- is done only after a checkpoint — check green + checkpoint taken, via `/pb-verify` or
21
- `/pb-build`.)*
21
+ *(Mirror of intent.md's Steps, with live status — CLI-maintained, not hand-edited.
22
+ `build`/`checkpoint`/`revert` re-render this from intent.md, and set the Current step
23
+ line above. Only ONE step is in flight; a step is done only after a checkpoint —
24
+ check green + checkpoint taken, via `/pb-verify` or `/pb-build`.)*
22
25
 
23
26
  - ☐ 1. <step>
24
27
 
@@ -50,11 +50,13 @@ which `/pb-build` records in the build folder's `SEAM` for orientation — aware
50
50
  An optional **model** line recommends the smallest model that can carry the step —
51
51
  mechanical work runs fine on a small model; subtle or creative work earns a frontier
52
52
  one. Advisory for the human, never a gate; write it plain, no backticks (D62).
53
- Then drive `/pb-build` until done. Later steps may be fuzzier than the first;
54
- sharpen the next one just-in-time with `/pb-step` (empty input auto-syncs it), and use
55
- `/pb-refine` to repair the whole plan when a blocker rewrites it.)*
53
+ A step title may open with a Conventional-Commit type (`fix:`, `feat(scope):`) — it
54
+ becomes the checkpoint's commit subject; skip it and the subject defaults to `feat`,
55
+ scoped by the build slug (D68). Then drive `/pb-build` until done. Later steps may be
56
+ fuzzier than the first; sharpen the next one just-in-time with `/pb-step` (empty input
57
+ auto-syncs it), and use `/pb-refine` to repair the whole plan when a blocker rewrites it.)*
56
58
 
57
- 1. [ ] <step> — **done when:** <criterion, ideally a test or check result>
59
+ 1. [ ] feat: <step> — **done when:** <criterion, ideally a test or check result>
58
60
  - seam: `<file>`, `<file>`
59
61
  - model: <smallest that can carry it, e.g. sonnet — mechanical, fully specified>
60
62
  2. [ ] <step> — **done when:** <criterion>
@@ -0,0 +1,38 @@
1
+ <!--
2
+ spike-report.md — the durable receipt for a spike (a fork the plan couldn't settle
3
+ on paper). One per spike, numbered and named `spike-NN-<slug>.md`, riding the build
4
+ folder into the PR beside intent.md / report.md. Fill it while the experiment is live
5
+ — the worktrees and their learnings are torn down at `spike done`, but this stays.
6
+
7
+ Two ways a spike is born, both landing here:
8
+ - `/pb-spike <slug>` — an explicit mid-build fork with throwaway worktrees.
9
+ - a planned step titled `spike: …` — the increment IS the experiment.
10
+ Record the Verdict before you close the spike (or check off the step).
11
+ -->
12
+
13
+ # Spike — {{TITLE}}
14
+
15
+ **Via:** {{VIA}}
16
+ **Opened:** {{DATE}}
17
+
18
+ ## Question
19
+
20
+ *(The fork this resolves, in one or two lines. What are we uncertain about, and what
21
+ would each answer commit us to?)*
22
+
23
+ ## Options tried
24
+
25
+ *(Each path you actually ran, and what it took — one bullet each.)*
26
+
27
+ ## Findings
28
+
29
+ *(What the experiment showed — evidence, not opinion. Numbers, errors, surprises.)*
30
+
31
+ ## Verdict
32
+
33
+ **Verdict:** *(viable | not viable | partial — replace this line with the call and why)*
34
+
35
+ ## What this decides
36
+
37
+ *(The intent.md Decision this feeds — which option won, what dies, and the one reason
38
+ that settled it. Copy the call into intent.md's Decisions / Verdicts too.)*