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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +56 -44
- package/dist/cli-core.js +35 -15
- package/dist/lib/agents.js +230 -112
- package/dist/lib/buildlog.js +50 -30
- package/dist/lib/buildlogsync.js +21 -12
- package/dist/lib/check.js +69 -35
- package/dist/lib/commitmsg.js +53 -32
- package/dist/lib/git.js +86 -31
- package/dist/lib/intent.js +91 -31
- package/dist/lib/latch.js +70 -39
- package/dist/lib/orient.js +78 -39
- package/dist/lib/plugins.js +11 -8
- package/dist/lib/settings.js +65 -32
- package/dist/lib/sidecar.js +320 -150
- package/dist/lib/templates.js +17 -6
- package/dist/verbs/agent.js +116 -55
- package/dist/verbs/build.js +27 -12
- package/dist/verbs/check.js +33 -7
- package/dist/verbs/checkpoint.js +153 -78
- package/dist/verbs/doctor.js +192 -95
- package/dist/verbs/finish.js +87 -44
- package/dist/verbs/handoff.js +56 -20
- package/dist/verbs/init.js +37 -12
- package/dist/verbs/park.js +14 -3
- package/dist/verbs/revert.js +64 -24
- package/dist/verbs/spike.js +89 -30
- package/dist/verbs/start.js +72 -38
- package/dist/verbs/status.js +44 -17
- package/dist/verbs/turn.js +88 -45
- package/dist/verbs/use.js +20 -9
- package/package.json +2 -2
- package/skills/{pb-build → build}/SKILL.md +25 -25
- package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
- package/skills/{pb-finish → finish}/SKILL.md +18 -5
- package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
- package/skills/{pb-park → park}/SKILL.md +2 -2
- package/skills/{pb-plan → plan}/SKILL.md +11 -11
- package/skills/{pb-refine → refine}/SKILL.md +7 -7
- package/skills/{pb-revert → revert}/SKILL.md +2 -2
- package/skills/{pb-spike → spike}/SKILL.md +4 -4
- package/skills/{pb-status → status}/SKILL.md +3 -3
- package/skills/{pb-step → step}/SKILL.md +9 -9
- package/skills/{pb-verify → verify}/SKILL.md +8 -8
- package/templates/build-log.md +7 -7
- package/templates/intent.md +8 -8
- package/templates/spike-report.md +1 -1
package/dist/lib/buildlog.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
// Mutations to the active build-log.md — the human-facing ledger
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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,
|
|
60
|
-
// the prose to preserve (the instructions paragraph). Rebuild: prose, one
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
package/dist/lib/buildlogsync.js
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// the
|
|
7
|
-
//
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
// - a `check` setting resolved through the ladder (flag →
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
8
|
-
// slots are reported with their `.check/` raw-output
|
|
9
|
-
// all-slots-skipped run REFUSES rather than green-lighting
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
89
|
-
//
|
|
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
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 ||
|
package/dist/lib/commitmsg.js
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
|
-
// Conventional-Commit subject assembly
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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] ?? '';
|