plumbbob 0.8.17 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +56 -44
  3. package/dist/cli-core.js +35 -15
  4. package/dist/lib/agents.js +230 -112
  5. package/dist/lib/buildlog.js +50 -30
  6. package/dist/lib/buildlogsync.js +21 -12
  7. package/dist/lib/check.js +69 -35
  8. package/dist/lib/commitmsg.js +53 -32
  9. package/dist/lib/git.js +86 -31
  10. package/dist/lib/intent.js +91 -31
  11. package/dist/lib/latch.js +70 -39
  12. package/dist/lib/orient.js +78 -39
  13. package/dist/lib/plugins.js +11 -8
  14. package/dist/lib/settings.js +65 -32
  15. package/dist/lib/sidecar.js +320 -150
  16. package/dist/lib/templates.js +17 -6
  17. package/dist/verbs/agent.js +116 -55
  18. package/dist/verbs/build.js +27 -12
  19. package/dist/verbs/check.js +33 -7
  20. package/dist/verbs/checkpoint.js +153 -78
  21. package/dist/verbs/doctor.js +192 -95
  22. package/dist/verbs/finish.js +87 -44
  23. package/dist/verbs/handoff.js +56 -20
  24. package/dist/verbs/init.js +37 -12
  25. package/dist/verbs/park.js +14 -3
  26. package/dist/verbs/revert.js +64 -24
  27. package/dist/verbs/spike.js +89 -30
  28. package/dist/verbs/start.js +72 -38
  29. package/dist/verbs/status.js +44 -17
  30. package/dist/verbs/turn.js +88 -45
  31. package/dist/verbs/use.js +20 -9
  32. package/package.json +2 -2
  33. package/skills/{pb-build → build}/SKILL.md +25 -25
  34. package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
  35. package/skills/{pb-finish → finish}/SKILL.md +18 -5
  36. package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
  37. package/skills/{pb-park → park}/SKILL.md +2 -2
  38. package/skills/{pb-plan → plan}/SKILL.md +11 -11
  39. package/skills/{pb-refine → refine}/SKILL.md +7 -7
  40. package/skills/{pb-revert → revert}/SKILL.md +2 -2
  41. package/skills/{pb-spike → spike}/SKILL.md +4 -4
  42. package/skills/{pb-status → status}/SKILL.md +3 -3
  43. package/skills/{pb-step → step}/SKILL.md +9 -9
  44. package/skills/{pb-verify → verify}/SKILL.md +8 -8
  45. package/templates/build-log.md +7 -7
  46. package/templates/intent.md +8 -8
  47. package/templates/spike-report.md +1 -1
@@ -1,13 +1,18 @@
1
- // Mutations to the active build-log.md — the human-facing ledger. `park` appends to
2
- // its Park list; `checkpoint` appends to its Log so the build's history accrues
3
- // line-by-line *as it happens* rather than being reconstructed at finish (D-history).
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.
8
- // Append `line` after the last non-blank line of the `## <heading>` section — i.e.
9
- // just before the next `## ` heading or EOF. Returns null when the section is absent
10
- // so the caller can report rather than silently corrupt the doc.
1
+ // Mutations to the active build-log.md — the human-facing ledger inside the
2
+ // build's tracked `builds/<slug>/` folder. `park` appends to its Park list;
3
+ // `checkpoint` appends to its Log so the build's history accrues line-by-line
4
+ // *as it happens* rather than being reconstructed at finish. The `## Steps`
5
+ // mirror and `**Current step:**` line are CLI-owned: `build`, `checkpoint`,
6
+ // and `revert` re-render them from intent.md so the ledger's top half tracks
7
+ // reality without a model turn. Pure string transforms — no fs, no classes;
8
+ // callers own the IO so these stay trivially testable.
9
+ /**
10
+ * Append `line` after the last non-blank line of the `## <heading>` section —
11
+ * i.e. just before the next `## ` heading or EOF.
12
+ *
13
+ * Returns null when the section is absent so the caller can report rather than
14
+ * silently corrupt the doc.
15
+ */
11
16
  export function appendToSection(content, heading, line) {
12
17
  const lines = content.split('\n');
13
18
  const headingIdx = lines.findIndex((l) => l.trim() === `## ${heading}`);
@@ -27,10 +32,14 @@ export function appendToSection(content, heading, line) {
27
32
  lines.splice(insertAt, 0, line);
28
33
  return lines.join('\n');
29
34
  }
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).
35
+ /**
36
+ * Replace the `**Current step:**` line with `**Current step:** <label>` —
37
+ * `build` sets it to `<n> — <title>` when a step opens; `checkpoint`/`revert`
38
+ * reset it to `none (at the boundary)`.
39
+ *
40
+ * Returns null when the line is absent so the caller skips rather than
41
+ * corrupting the doc.
42
+ */
34
43
  export function setCurrentStep(content, label) {
35
44
  const lines = content.split('\n');
36
45
  const idx = lines.findIndex((l) => /^\*\*Current step:\*\*/.test(l.trim()));
@@ -40,12 +49,17 @@ export function setCurrentStep(content, label) {
40
49
  lines[idx] = `**Current step:** ${label}`;
41
50
  return lines.join('\n');
42
51
  }
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.
52
+ /**
53
+ * Regenerate the `## Steps` mirror from intent.md's parsed steps — one
54
+ * `- ☑/☐ N. <title>` line each, in order.
55
+ *
56
+ * Only the mirror's own `- ☑/☐` list lines are owned; the italic instructions
57
+ * paragraph (and any other prose) is preserved, and exactly one blank line
58
+ * separates it from the list. Returns null when the section is absent so the
59
+ * caller skips rather than corrupting the doc. With no steps the mirror
60
+ * renders empty — the plan is not authored yet — rather than inventing a
61
+ * placeholder.
62
+ */
49
63
  export function syncStepsSection(content, steps) {
50
64
  const lines = content.split('\n');
51
65
  const headingIdx = lines.findIndex((l) => l.trim() === '## Steps');
@@ -56,9 +70,9 @@ export function syncStepsSection(content, steps) {
56
70
  if (end === -1) {
57
71
  end = lines.length;
58
72
  }
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.
73
+ // The section body minus the mirror list lines, with trailing blanks trimmed,
74
+ // is the prose to preserve (the instructions paragraph). Rebuild: prose, one
75
+ // blank, the fresh list, one blank before the next heading.
62
76
  const prose = lines.slice(headingIdx + 1, end).filter((l) => !isMirrorLine(l));
63
77
  while (prose.length > 0 && (prose.at(-1) ?? '').trim() === '') {
64
78
  prose.pop();
@@ -67,16 +81,22 @@ export function syncStepsSection(content, steps) {
67
81
  const body = [...prose, ...(prose.length > 0 ? [''] : []), ...list, ''];
68
82
  return [...lines.slice(0, headingIdx + 1), ...body, ...lines.slice(end)].join('\n');
69
83
  }
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.
84
+ /**
85
+ * A mirror list line the CLI owns: `- ☑ …` / `- ☐ …`. The template's initial
86
+ * `- ☐ 1. <step>` placeholder matches, so the first sync replaces it in place.
87
+ */
72
88
  function isMirrorLine(line) {
73
89
  return /^-\s+[☑☐]/.test(line);
74
90
  }
75
- // The Log line `checkpoint` writes for a landed step: dated, names the step (its
76
- // title when intent.md still carries it), and carries the short SHA. One line of the
77
- // build's history — `/pb-finish` reads these instead of re-narrating the build.
78
- // `stats` is the optional compact receipt (research/07 2b), e.g. `2 red, 34m` —
79
- // null when nothing accrued, so a clean first-try step reads exactly as before.
91
+ /**
92
+ * The Log line `checkpoint` writes for a landed step: dated, names the step
93
+ * (its title when intent.md still carries it), and carries the short SHA.
94
+ *
95
+ * One line of the build's history — `/plumbbob:finish` reads these instead of
96
+ * re-narrating the build. `stats` is the optional compact receipt of what the
97
+ * step cost (e.g. `2 red, 34m`) — null when nothing accrued, so a clean
98
+ * first-try step reads as a bare line.
99
+ */
80
100
  export function checkpointLogLine(date, step, sha, title, stats = null) {
81
101
  const head = `- ${date} — step ${step} checkpointed · ${sha.slice(0, 9)}`;
82
102
  const titled = title !== null && title.length > 0 ? `${head} — ${title}` : head;
@@ -1,24 +1,33 @@
1
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.
2
+ // mirror — in sync with intent.md, the build's plan document. The build-log is the
3
+ // human-facing ledger in the build's tracked `builds/<slug>/` folder; its top half
4
+ // is CLI-owned so it never lies: `build` sets the current-step label when a step
5
+ // opens, `checkpoint` and `revert` reset it to the boundary, and all three
6
+ // re-render the mirror from intent.md's parsed steps so it tracks reality without
7
+ // a model turn. The write is best-effort (a missing or hand-edited build-log never
8
+ // fails a verb — the checkpoints ledger and intent.md stay the source of truth),
9
+ // matching how `checkpoint`'s Log append behaves.
8
10
  import { readFileSync, writeFileSync } from 'node:fs';
9
11
  import { buildLogPath, intentPath } from "./sidecar.js";
10
12
  import { parseSteps } from "./orient.js";
11
13
  import { setCurrentStep, syncStepsSection } from "./buildlog.js";
14
+ /** The Current-step label for no step in flight — the loop is at the step boundary. */
12
15
  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.
16
+ /**
17
+ * The current-step label for an open step: `<n> — <title>`, or a bare `<n>`
18
+ * when the step carries no title. `build` passes this; the boundary verbs pass
19
+ * AT_BOUNDARY.
20
+ */
15
21
  export function stepLabel(n, title) {
16
22
  return title !== null && title.trim().length > 0 ? `${n} — ${title.trim()}` : String(n);
17
23
  }
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.
24
+ /**
25
+ * Rewrite the active build's build-log top half: set the Current step line to
26
+ * `label` and re-render the `## Steps` mirror from intent.md.
27
+ *
28
+ * Each transform is skipped when its target is absent (null), and the whole
29
+ * thing swallows any IO error — the ledger must never block the verb.
30
+ */
22
31
  export function syncBuildLogState(root, slug, label) {
23
32
  try {
24
33
  const path = buildLogPath(root, slug);
package/dist/lib/check.js CHANGED
@@ -1,19 +1,30 @@
1
- // The heavy check (D16/D24/D32): the full gate that `verify` and `checkpoint`
2
- // refuse to advance past while red. Two paths through one seam:
3
- // - a `check` setting resolved through the ladder (flag → settings.local.json
4
- // → settings.json, D27) is a shell command → spawn it exactly as before, so
5
- // any repo can gate through anything (tests point it at `true`/`false`, D14);
1
+ // The heavy check: the full quality gate that `verify` and `checkpoint` refuse
2
+ // to advance past while red. Two paths through one seam:
3
+ // - a `check` setting resolved through the settings ladder (CLI flag →
4
+ // settings.local.json, the untracked personal overlay → settings.json, the
5
+ // tracked project defaults) is a shell command → spawn it verbatim, so any
6
+ // repo can gate through anything (test fixtures point it at `true`/`false`
7
+ // to keep throwaway repos deterministic);
6
8
  // - no setting at all → checkride, our sibling package, imported
7
- // programmatically (D32): the typed summary comes back in-process, failing
8
- // slots are reported with their `.check/` raw-output pointers, and an
9
- // all-slots-skipped run REFUSES rather than green-lighting a repo checkride
10
- // can't see (a vacuous pass is not a pass).
9
+ // programmatically rather than spawned: the typed summary comes back
10
+ // in-process, failing slots are reported with their `.check/` raw-output
11
+ // pointers, and an all-slots-skipped run REFUSES rather than green-lighting
12
+ // a repo checkride can't see (a vacuous pass is not a pass).
11
13
  // Checkride's stream discipline holds: human progress goes to stderr. Exit 0 is
12
14
  // green, 1 is red, 2 means the harness itself broke — reported distinctly,
13
15
  // because a misconfigured gate must not read as broken code (both still block).
14
16
  import { spawnSync } from 'node:child_process';
15
17
  import { runChecks, runDoctor } from 'checkride';
16
18
  import { resolveString } from "./settings.js";
19
+ /**
20
+ * Probe whether the check gate would have anything to run in `root`.
21
+ *
22
+ * A configured `check` command answers yes on its own; otherwise checkride's
23
+ * doctor detection runs in-process — the same pass `plumbbob doctor` reports
24
+ * as a table. `start` reads this to surface "nothing to check" at plan time,
25
+ * while the human is still deciding, instead of at the first refused
26
+ * checkpoint.
27
+ */
17
28
  export async function detectGate(root) {
18
29
  const command = resolveString(root, 'check', '');
19
30
  if (command.length > 0)
@@ -30,28 +41,36 @@ export async function detectGate(root) {
30
41
  return { configured: null, detected: true };
31
42
  }
32
43
  }
33
- // Checkride's always-on repo checks: the adapters its doctor reports even for
34
- // an EMPTY directory — the built-in `links` slot, the pnpm-audit security
35
- // scan, the publint/attw package-shape probes, and (checkride 0.5.0's
36
- // publish bundle) the built-in pack/smoke/snippets slots, which resolve on any
37
- // package with no tool config of their own. They exercise the repo's plumbing
38
- // or its shipped artifact, not its code — a gate made only of these green-lights
39
- // every checkpoint while the human believes their work is being checked. Coupled
40
- // to checkride's built-in set by construction; the check.test.ts and
41
- // doctor.test.ts probes pin the coupling.
44
+ /**
45
+ * Checkride's always-on repo checks: the adapters its doctor reports even for
46
+ * an EMPTY directory — the built-in `links` slot, the pnpm-audit security
47
+ * scan, the publint/attw package-shape probes, and the built-in
48
+ * pack/smoke/snippets publish-bundle slots, which resolve on any package with
49
+ * no tool config of their own. They exercise the repo's plumbing or its
50
+ * shipped artifact, not its code — a gate made only of these green-lights
51
+ * every checkpoint while the human believes their work is being checked.
52
+ * Coupled to checkride's built-in set by construction; the check.test.ts and
53
+ * doctor.test.ts probes pin the coupling.
54
+ */
42
55
  const ALWAYS_ON_ADAPTERS = ['links', 'pnpm-audit', 'publint', 'attw', 'pack', 'smoke', 'snippets'];
43
- // The detection rule, shared by `start`'s probe and doctor's callout: some tool
44
- // slot beyond the always-on family has an adapter — i.e. checkride can see the
45
- // CODE (a tsconfig, a test runner, a linter…), not just the repo.
56
+ /**
57
+ * The detection rule, shared by `start`'s probe and doctor's callout: some
58
+ * tool slot beyond the always-on family has an adapter — i.e. checkride can
59
+ * see the CODE (a tsconfig, a test runner, a linter…), not just the repo.
60
+ */
46
61
  export function gateDetectsTools(checks) {
47
62
  return checks.some((c) => c.category === 'tool' &&
48
63
  c.adapter !== null &&
49
64
  c.adapter !== undefined &&
50
65
  !ALWAYS_ON_ADAPTERS.includes(c.adapter));
51
66
  }
52
- // Runs the gate in `root` and returns the exit code (0 green, 1 red, 2 harness
53
- // error). `commandFlag` is the optional CLI override at the top of the settings
54
- // ladder; a resolved command means the spawn path, no setting means checkride.
67
+ /**
68
+ * Run the gate in `root` and return the exit code (0 green, 1 red, 2 harness
69
+ * error).
70
+ *
71
+ * `commandFlag` is the optional CLI override at the top of the settings
72
+ * ladder; a resolved command means the spawn path, no setting means checkride.
73
+ */
55
74
  export async function runCheck(root, flags = {}, commandFlag) {
56
75
  const command = resolveString(root, 'check', '', commandFlag);
57
76
  if (command.length > 0) {
@@ -59,8 +78,11 @@ export async function runCheck(root, flags = {}, commandFlag) {
59
78
  }
60
79
  return runCheckride(root, flags);
61
80
  }
62
- // The spawn override, byte-for-byte the pre-D32 behavior: stream the command's
63
- // own output to the terminal and return its exit code.
81
+ /**
82
+ * The spawn override: stream the configured command's own output to the
83
+ * terminal and return its exit code — the command is trusted verbatim, no
84
+ * interpretation.
85
+ */
64
86
  function runCommand(root, command, flags) {
65
87
  if (hasFlags(flags)) {
66
88
  process.stderr.write(`plumbbob: check flags only narrow the checkride gate — ignored for the configured command '${command}'.\n`);
@@ -68,6 +90,10 @@ function runCommand(root, command, flags) {
68
90
  const result = spawnSync(command, { cwd: root, shell: true, stdio: 'inherit' });
69
91
  return result.status ?? 1;
70
92
  }
93
+ /**
94
+ * The checkride path: run the gate in-process, refuse an all-skipped run, and
95
+ * name the failing slots on red.
96
+ */
71
97
  async function runCheckride(root, flags) {
72
98
  let summary;
73
99
  let exitCode;
@@ -85,8 +111,9 @@ async function runCheckride(root, flags) {
85
111
  exitCode = result.exitCode;
86
112
  }
87
113
  catch (err) {
88
- // The harness broke, not the code (D32): a malformed checkride.config.json
89
- // or the like. Checkride's own CLI maps this to exit 2; so do we.
114
+ // The harness broke, not the code: a malformed checkride.config.json or
115
+ // the like. Checkride's own CLI maps this to exit 2; so do we — a
116
+ // misconfigured gate must report distinctly from red.
90
117
  const message = err instanceof Error ? err.message : String(err);
91
118
  process.stderr.write(`plumbbob: the check gate itself failed — ${message}\n` +
92
119
  ` Fix checkride.config.json, or set the "check" key in .plumbbob/settings.json to gate through another command.\n`);
@@ -94,9 +121,9 @@ async function runCheckride(root, flags) {
94
121
  }
95
122
  if (summary.checks.every((c) => c.skipped === true || c.name === 'links')) {
96
123
  // Zero-config checkride skips slots with no detected tool; a repo it can't
97
- // see must refuse, not vacuously green-light the checkpoint (D32). The
98
- // built-in `links` slot is always-on (empty detect list), so it alone
99
- // proves nothing about the code — a links-only run is still vacuous.
124
+ // see must refuse, not vacuously green-light the checkpoint. The built-in
125
+ // `links` slot is always-on (empty detect list), so it alone proves
126
+ // nothing about the code — a links-only run is still vacuous.
100
127
  process.stderr.write('plumbbob: checkride found nothing to check in this repo — refusing to call that green.\n' +
101
128
  ' Add tool configs (tsconfig, vitest, …) or a checkride.config.json custom check,\n' +
102
129
  ' or set the "check" key in .plumbbob/settings.json to gate through another command.\n');
@@ -107,10 +134,13 @@ async function runCheckride(root, flags) {
107
134
  }
108
135
  return exitCode;
109
136
  }
110
- // Name the failing slots and where their raw diagnostics landed, so the agent
111
- // (pb-verify) reads the tool's own JSON instead of scraping scrollback. The
112
- // summary is the canonical pointer — `output_file` is the adapter's preferred
113
- // name, but non-JSON output falls back to `.check/<slot>.stdout.txt`.
137
+ /**
138
+ * Name the failing slots and where their raw diagnostics landed, so the agent
139
+ * (verify) reads the tool's own JSON instead of scraping scrollback.
140
+ *
141
+ * The summary is the canonical pointer — `output_file` is the adapter's
142
+ * preferred name, but non-JSON output falls back to `.check/<slot>.stdout.txt`.
143
+ */
114
144
  function reportFailingSlots(summary) {
115
145
  const failing = summary.checks.filter((c) => !c.ok);
116
146
  const lines = failing.map((c) => {
@@ -120,6 +150,10 @@ function reportFailingSlots(summary) {
120
150
  });
121
151
  process.stderr.write(`plumbbob: failing slots:\n${lines.join('\n')}\n Full report: .check/summary.json\n`);
122
152
  }
153
+ /**
154
+ * True when any narrowing flag was passed — the trigger for the spawn path's
155
+ * flags-are-ignored warning.
156
+ */
123
157
  function hasFlags(flags) {
124
158
  return (flags.bail === true ||
125
159
  flags.changed === true ||
@@ -1,23 +1,31 @@
1
- // Conventional-Commit subject assembly (D68). Plumbbob's commit subjects read as
1
+ // Conventional-Commit subject assembly. Plumbbob's commit subjects read as
2
2
  // `type(scope): description` — real feat/fix/chore types and a real parenthesised
3
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.
4
+ // `plumbbob`/`step N` identifiers ride a marker line at the head of the body,
5
+ // which `withMarker` prepends — so `git log --grep plumbbob` finds every plumbbob
6
+ // commit. Functional and procedural, node builtins only, no gate: these only
7
+ // shape a string the CLI is about to commit.
8
+ /**
9
+ * The Conventional-Commit type vocabulary plumbbob recognises as a leading
10
+ * prefix on a step title.
11
+ *
12
+ * Anchoring on this closed set keeps a prose title like `Note: rewire the
13
+ * cache` from being misread as a `Note` type — only these words, immediately
14
+ * followed by an optional `(scope)`, an optional `!`, and a colon, count as an
15
+ * author-supplied prefix.
16
+ */
14
17
  const TYPES = ['feat', 'fix', 'chore', 'docs', 'refactor', 'test', 'perf', 'build', 'ci', 'style', 'revert'];
18
+ // One anchored pattern for the whole prefix: type, optional (scope), optional
19
+ // breaking !, colon, then the description.
15
20
  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
+ /**
22
+ * Split an optional Conventional prefix off a step title.
23
+ *
24
+ * `feat(escape-hatch): add the flag` → { type:'feat', scope:'escape-hatch',
25
+ * breaking:false, description:'add the flag' }. A title with no recognised
26
+ * prefix comes back whole as the description with a null type/scope — the
27
+ * caller then defaults the type and fills the scope from the build.
28
+ */
21
29
  export function parseConventionalTitle(title) {
22
30
  const m = PREFIX.exec(title.trim());
23
31
  if (m === null) {
@@ -25,20 +33,26 @@ export function parseConventionalTitle(title) {
25
33
  }
26
34
  return { type: m[1] ?? null, scope: m[2] ?? null, breaking: m[3] === '!', description: (m[4] ?? '').trim() };
27
35
  }
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.
36
+ /**
37
+ * Assemble a `type(scope)!: description` subject, omitting the `(scope)`
38
+ * segment when there is no scope (Conventional Commits allow a bare
39
+ * `type: description`) and the `!` when the change is not breaking.
40
+ */
31
41
  export function conventionalSubject(parts) {
32
42
  const scope = parts.scope ? `(${parts.scope})` : '';
33
43
  const bang = parts.breaking ? '!' : '';
34
44
  return `${parts.type}${scope}${bang}: ${parts.description}`;
35
45
  }
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.
46
+ /**
47
+ * A Conventional subject from a step title, filling gaps from the build.
48
+ *
49
+ * An author-written prefix (`fix(parser): …`) is honoured verbatim — its type,
50
+ * scope, breaking marker, and exact wording all win. A bare prose title
51
+ * defaults to `defaultType` and the build `scope`, and gets its sentence-case
52
+ * opener de-capitalised (`Add the flag` → `add the flag`) to match
53
+ * Conventional style — while an all-caps acronym start (`API`, `OAuth`) is
54
+ * left alone.
55
+ */
42
56
  export function subjectFromTitle(title, defaultType, scope) {
43
57
  const parsed = parseConventionalTitle(title);
44
58
  if (parsed.type !== null) {
@@ -51,17 +65,24 @@ export function subjectFromTitle(title, defaultType, scope) {
51
65
  }
52
66
  return conventionalSubject({ type: defaultType, scope, description: lowerFirst(parsed.description) });
53
67
  }
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.
68
+ /**
69
+ * Prepend a marker line (`plumbbob step 1`, `plumbbob plan`, `plumbbob
70
+ * finish`) to an optional body, separated by a blank line so git keeps them as
71
+ * distinct paragraphs.
72
+ *
73
+ * Always returns at least the marker, so every plumbbob commit carries the
74
+ * greppable identifier in its body even when the prose is empty.
75
+ */
58
76
  export function withMarker(marker, body) {
59
77
  const rest = body?.trim();
60
78
  return rest !== undefined && rest.length > 0 ? `${marker}\n\n${rest}` : marker;
61
79
  }
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`).
80
+ /**
81
+ * De-capitalise a sentence-case opener but leave an acronym/proper all-caps
82
+ * start (`API`, `OAuth`) intact: lowercase the first character only when it is
83
+ * uppercase and the second character is not (so `Add` → `add`, but `API` stays
84
+ * `API`).
85
+ */
65
86
  function lowerFirst(text) {
66
87
  const a = text[0] ?? '';
67
88
  const b = text[1] ?? '';