plumbbob 0.8.11 → 0.8.12

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.12",
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",
@@ -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) {
@@ -9,11 +9,12 @@
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";
18
19
  export async function checkpoint(cwd, args) {
19
20
  const root = findRepoRoot(cwd);
@@ -53,7 +54,7 @@ export async function checkpoint(cwd, args) {
53
54
  if (isDirty(root)) {
54
55
  stageAll(root);
55
56
  warnScopeDrift(root, step);
56
- const body = bodyArg(args) ?? fallbackBody(root, step);
57
+ const body = withMarker(`plumbbob step ${step}`, bodyArg(args) ?? fallbackBody(root, step));
57
58
  sha = commit(root, messageArg(args) ?? subjectForStep(root, step), body);
58
59
  }
59
60
  else {
@@ -71,12 +72,12 @@ export async function checkpoint(cwd, args) {
71
72
  return 0;
72
73
  }
73
74
  // 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
75
+ // commit it as `chore(<scope>): plan` (D68), then record a `plan <sha>` line. Giving
75
76
  // the plan its own commit keeps the first step's diff from absorbing the scaffold, so
76
77
  // `git log` reads baseline → plan → steps. No check gate (there is no code work to
77
78
  // 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.
79
+ // An optional `--body` (stdin heredoc, D68) rides after the `plumbbob plan` marker;
80
+ // the folder is whitelisted artifact plane, so there is no scope-drift to warn about.
80
81
  function checkpointPlan(root, args) {
81
82
  // The latch (D64) covers the plan commit too, keyed on the TICK that `start`
82
83
  // stamped: the plan pause is a pause. No step number — a range grant does not
@@ -87,7 +88,7 @@ function checkpointPlan(root, args) {
87
88
  return 1;
88
89
  }
89
90
  stagePath(root, buildFolder(root));
90
- const sha = commit(root, planSubject(root), bodyArg(args) ?? undefined);
91
+ const sha = commit(root, planSubject(root), withMarker('plumbbob plan', bodyArg(args) ?? undefined));
91
92
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
92
93
  // The plan landing consumes `start`'s entry stamp (D64): a later hand-built diff
93
94
  // (no `build <n>`) must find no stale TICK and stay guidance-governed.
@@ -95,17 +96,11 @@ function checkpointPlan(root, args) {
95
96
  process.stdout.write(`plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
96
97
  return 0;
97
98
  }
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).
99
+ // The plan commit's CLI-owned subject (D68): `chore(<scope>): plan`, the scope drawn
100
+ // from the active build's slug (a bare `chore: plan` when none resolves). The
101
+ // `plumbbob plan` identifier rides the body marker, not the subject.
100
102
  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';
103
+ return conventionalSubject({ type: 'chore', scope: buildScope(activeBuild(root)), description: 'plan' });
109
104
  }
110
105
  // Step resolution (D3): explicit arg > in-flight STEP file > first undone step in
111
106
  // intent.md. Returns null when none can be determined. A `-m` value is a message,
@@ -209,12 +204,18 @@ function statsSuffix(root, step) {
209
204
  }
210
205
  return parts.length > 0 ? parts.join(', ') : null;
211
206
  }
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).
207
+ // The CLI-owned, deterministic commit subject (D68): a Conventional `type(scope):
208
+ // description`. The scope comes from the active build's slug; the type and wording
209
+ // come from the step's title — an author-written prefix (`fix(parser): …`) is honoured
210
+ // verbatim, a bare title defaults to `feat`. A titleless step falls back to
211
+ // `chore(<scope>): checkpoint`. The `plumbbob`/`step N` identifiers ride the body
212
+ // marker, not the subject; a `-m` override or `--body` prose is a separate concern.
215
213
  function subjectForStep(root, step) {
214
+ const scope = buildScope(activeBuild(root));
216
215
  const title = titleForStep(root, step);
217
- return title ? `plumbbob: step ${step} — ${title}` : `plumbbob: step ${step} done`;
216
+ return title !== null && title.trim().length > 0
217
+ ? subjectFromTitle(title, 'feat', scope)
218
+ : conventionalSubject({ type: 'chore', scope, description: 'checkpoint' });
218
219
  }
219
220
  function titleForStep(root, step) {
220
221
  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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
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",
@@ -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
@@ -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'
@@ -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>