plumbbob 0.9.5 → 0.10.0

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 (142) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +87 -86
  3. package/dist/cli-core.js +8 -8
  4. package/dist/cli.js +1 -1
  5. package/dist/lib/agents.js +26 -26
  6. package/dist/lib/buildlog.js +11 -11
  7. package/dist/lib/buildlogsync.js +8 -8
  8. package/dist/lib/check.js +14 -14
  9. package/dist/lib/commitbody.js +72 -0
  10. package/dist/lib/commitmsg.js +7 -7
  11. package/dist/lib/git.js +44 -15
  12. package/dist/lib/intent.js +20 -19
  13. package/dist/lib/latch.js +16 -16
  14. package/dist/lib/orient.js +36 -17
  15. package/dist/lib/reentry.js +3 -3
  16. package/dist/lib/settings.js +16 -16
  17. package/dist/lib/sidecar.js +54 -54
  18. package/dist/lib/templates.js +1 -1
  19. package/dist/verbs/agent.js +25 -25
  20. package/dist/verbs/build.js +9 -9
  21. package/dist/verbs/check.js +6 -6
  22. package/dist/verbs/checkpoint.js +48 -47
  23. package/dist/verbs/doctor.js +27 -26
  24. package/dist/verbs/finish.js +27 -40
  25. package/dist/verbs/handoff.js +9 -8
  26. package/dist/verbs/init.js +9 -9
  27. package/dist/verbs/park.js +3 -3
  28. package/dist/verbs/recover.js +11 -11
  29. package/dist/verbs/revert.js +12 -12
  30. package/dist/verbs/spike.js +17 -17
  31. package/dist/verbs/start.js +49 -19
  32. package/dist/verbs/status.js +8 -8
  33. package/dist/verbs/turn.js +16 -16
  34. package/dist/verbs/use.js +4 -4
  35. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  36. package/node_modules/checkride/CHANGELOG.md +401 -0
  37. package/node_modules/checkride/README.md +27 -9
  38. package/node_modules/checkride/dist/adapters.d.ts +12 -4
  39. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  40. package/node_modules/checkride/dist/adapters.js +21 -1
  41. package/node_modules/checkride/dist/adapters.js.map +1 -1
  42. package/node_modules/checkride/dist/agent-setup/cursor.d.ts +9 -1
  43. package/node_modules/checkride/dist/agent-setup/cursor.d.ts.map +1 -1
  44. package/node_modules/checkride/dist/agent-setup/cursor.js +59 -1
  45. package/node_modules/checkride/dist/agent-setup/cursor.js.map +1 -1
  46. package/node_modules/checkride/dist/agent-setup/harness.d.ts +1 -1
  47. package/node_modules/checkride/dist/agent-setup/harness.d.ts.map +1 -1
  48. package/node_modules/checkride/dist/agent-setup/harness.js +10 -1
  49. package/node_modules/checkride/dist/agent-setup/harness.js.map +1 -1
  50. package/node_modules/checkride/dist/agent-setup/scripts.d.ts +23 -0
  51. package/node_modules/checkride/dist/agent-setup/scripts.d.ts.map +1 -1
  52. package/node_modules/checkride/dist/agent-setup/scripts.js +387 -82
  53. package/node_modules/checkride/dist/agent-setup/scripts.js.map +1 -1
  54. package/node_modules/checkride/dist/baseline/fallow.js +2 -2
  55. package/node_modules/checkride/dist/baseline/fingerprint.d.ts +14 -5
  56. package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -1
  57. package/node_modules/checkride/dist/baseline/fingerprint.js +58 -5
  58. package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -1
  59. package/node_modules/checkride/dist/baseline/history.d.ts +98 -0
  60. package/node_modules/checkride/dist/baseline/history.d.ts.map +1 -0
  61. package/node_modules/checkride/dist/baseline/history.js +232 -0
  62. package/node_modules/checkride/dist/baseline/history.js.map +1 -0
  63. package/node_modules/checkride/dist/baseline/index.d.ts +4 -2
  64. package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -1
  65. package/node_modules/checkride/dist/baseline/index.js +2 -1
  66. package/node_modules/checkride/dist/baseline/index.js.map +1 -1
  67. package/node_modules/checkride/dist/baseline/store.d.ts +30 -7
  68. package/node_modules/checkride/dist/baseline/store.d.ts.map +1 -1
  69. package/node_modules/checkride/dist/baseline/store.js +50 -16
  70. package/node_modules/checkride/dist/baseline/store.js.map +1 -1
  71. package/node_modules/checkride/dist/cli.d.ts +5 -1
  72. package/node_modules/checkride/dist/cli.d.ts.map +1 -1
  73. package/node_modules/checkride/dist/cli.js +98 -25
  74. package/node_modules/checkride/dist/cli.js.map +1 -1
  75. package/node_modules/checkride/dist/config.d.ts +33 -4
  76. package/node_modules/checkride/dist/config.d.ts.map +1 -1
  77. package/node_modules/checkride/dist/config.js +21 -6
  78. package/node_modules/checkride/dist/config.js.map +1 -1
  79. package/node_modules/checkride/dist/digest/digest.js +1 -1
  80. package/node_modules/checkride/dist/doctor.d.ts +2 -0
  81. package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
  82. package/node_modules/checkride/dist/doctor.js +39 -1
  83. package/node_modules/checkride/dist/doctor.js.map +1 -1
  84. package/node_modules/checkride/dist/gate.d.ts +49 -4
  85. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  86. package/node_modules/checkride/dist/gate.js +143 -16
  87. package/node_modules/checkride/dist/gate.js.map +1 -1
  88. package/node_modules/checkride/dist/init.d.ts +8 -1
  89. package/node_modules/checkride/dist/init.d.ts.map +1 -1
  90. package/node_modules/checkride/dist/init.js +68 -7
  91. package/node_modules/checkride/dist/init.js.map +1 -1
  92. package/node_modules/checkride/dist/node-pin.d.ts +1 -1
  93. package/node_modules/checkride/dist/node-pin.js +1 -1
  94. package/node_modules/checkride/dist/orchestrator.d.ts +13 -0
  95. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  96. package/node_modules/checkride/dist/orchestrator.js +118 -13
  97. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  98. package/node_modules/checkride/dist/pm/index.d.ts +1 -0
  99. package/node_modules/checkride/dist/pm/index.d.ts.map +1 -1
  100. package/node_modules/checkride/dist/pm/index.js.map +1 -1
  101. package/node_modules/checkride/dist/pm/launch.d.ts +23 -2
  102. package/node_modules/checkride/dist/pm/launch.d.ts.map +1 -1
  103. package/node_modules/checkride/dist/pm/launch.js +16 -0
  104. package/node_modules/checkride/dist/pm/launch.js.map +1 -1
  105. package/node_modules/checkride/dist/pm/translate.js +1 -1
  106. package/node_modules/checkride/dist/pm/translate.js.map +1 -1
  107. package/node_modules/checkride/dist/qa/mutation.d.ts +4 -4
  108. package/node_modules/checkride/dist/qa/mutation.js +4 -4
  109. package/node_modules/checkride/dist/recover.d.ts +48 -0
  110. package/node_modules/checkride/dist/recover.d.ts.map +1 -0
  111. package/node_modules/checkride/dist/recover.js +220 -0
  112. package/node_modules/checkride/dist/recover.js.map +1 -0
  113. package/node_modules/checkride/dist/smoke.d.ts +1 -1
  114. package/node_modules/checkride/dist/smoke.js +1 -1
  115. package/node_modules/checkride/dist/snippets.d.ts +1 -1
  116. package/node_modules/checkride/package.json +3 -2
  117. package/node_modules/checkride/schema/checkride.config.schema.json +9 -1
  118. package/node_modules/checkride/skills/check/SKILL.md +9 -1
  119. package/node_modules/checkride/templates/shared/styles/Repo/Drift.yml +41 -0
  120. package/node_modules/checkride/templates/shared/styles/Repo/Latin.yml +14 -0
  121. package/node_modules/checkride/templates/shared/styles/Repo/LyHyphen.yml +28 -0
  122. package/node_modules/checkride/templates/shared/styles/Repo/Minted.yml +33 -0
  123. package/node_modules/checkride/templates/shared/styles/Repo/ThereIs.yml +16 -0
  124. package/node_modules/checkride/templates/shared/styles/Repo/Weasel.yml +26 -0
  125. package/node_modules/checkride/templates/shared/vale.ini +34 -0
  126. package/package.json +10 -9
  127. package/skills/build/SKILL.md +69 -64
  128. package/skills/doctor/SKILL.md +11 -11
  129. package/skills/finish/SKILL.md +20 -19
  130. package/skills/harvest/SKILL.md +13 -12
  131. package/skills/park/SKILL.md +10 -10
  132. package/skills/plan/SKILL.md +63 -54
  133. package/skills/recover/SKILL.md +6 -6
  134. package/skills/refine/SKILL.md +18 -16
  135. package/skills/revert/SKILL.md +6 -6
  136. package/skills/spike/SKILL.md +9 -9
  137. package/skills/status/SKILL.md +5 -5
  138. package/skills/step/SKILL.md +17 -12
  139. package/skills/verify/SKILL.md +45 -40
  140. package/templates/build-log.md +7 -7
  141. package/templates/intent.md +49 -39
  142. package/templates/spike-report.md +10 -10
@@ -1,14 +1,14 @@
1
- // Mutations to the active build-log.md — the human-facing ledger inside the
1
+ // Mutations to the active build-log.md: the human-facing ledger inside the
2
2
  // build's tracked `builds/<slug>/` folder. `park` appends to its Park list;
3
3
  // `checkpoint` appends to its Log so the build's history accrues line-by-line
4
4
  // *as it happens* rather than being reconstructed at finish. The `## Steps`
5
5
  // mirror and `**Current step:**` line are CLI-owned: `build`, `checkpoint`,
6
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;
7
+ // reality without a model turn. Pure string transforms: no fs, no classes;
8
8
  // callers own the IO so these stay trivially testable.
9
9
  /**
10
- * Append `line` after the last non-blank line of the `## <heading>` section —
11
- * i.e. just before the next `## ` heading or EOF.
10
+ * Append `line` after the last non-blank line of the `## <heading>` section:
11
+ * that is, just before the next `## ` heading or EOF.
12
12
  *
13
13
  * Returns null when the section is absent so the caller can report rather than
14
14
  * silently corrupt the doc.
@@ -33,9 +33,9 @@ export function appendToSection(content, heading, line) {
33
33
  return lines.join('\n');
34
34
  }
35
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)`.
36
+ * Replace the `**Current step:**` line with `**Current step:** <label>`:
37
+ * `build` sets it to the step's number and title joined by an em dash when a
38
+ * step opens; `checkpoint`/`revert` reset it to `none (at the boundary)`.
39
39
  *
40
40
  * Returns null when the line is absent so the caller skips rather than
41
41
  * corrupting the doc.
@@ -50,14 +50,14 @@ export function setCurrentStep(content, label) {
50
50
  return lines.join('\n');
51
51
  }
52
52
  /**
53
- * Regenerate the `## Steps` mirror from intent.md's parsed steps — one
53
+ * Regenerate the `## Steps` mirror from intent.md's parsed steps: one
54
54
  * `- ☑/☐ N. <title>` line each, in order.
55
55
  *
56
56
  * Only the mirror's own `- ☑/☐` list lines are owned; the italic instructions
57
57
  * paragraph (and any other prose) is preserved, and exactly one blank line
58
58
  * separates it from the list. Returns null when the section is absent so the
59
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
60
+ * renders empty (the plan is not authored yet) rather than inventing a
61
61
  * placeholder.
62
62
  */
63
63
  export function syncStepsSection(content, steps) {
@@ -92,9 +92,9 @@ function isMirrorLine(line) {
92
92
  * The Log line `checkpoint` writes for a landed step: dated, names the step
93
93
  * (its title when intent.md still carries it), and carries the short SHA.
94
94
  *
95
- * One line of the build's history — `/plumbbob:finish` reads these instead of
95
+ * One line of the build's history: `/plumbbob:finish` reads these instead of
96
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
97
+ * step cost (for example `2 red, 34m`): null when nothing accrued, so a clean
98
98
  * first-try step reads as a bare line.
99
99
  */
100
100
  export function checkpointLogLine(date, step, sha, title, stats = null) {
@@ -1,22 +1,22 @@
1
- // Keep the build-log's top half — the `**Current step:**` line and the `## Steps`
2
- // mirror — in sync with intent.md, the build's plan document. The build-log is the
1
+ // Keep the build-log's top half (the `**Current step:**` line and the `## Steps`
2
+ // mirror) in sync with intent.md, the build's plan document. The build-log is the
3
3
  // human-facing ledger in the build's tracked `builds/<slug>/` folder; its top half
4
4
  // is CLI-owned so it never lies: `build` sets the current-step label when a step
5
5
  // opens, `checkpoint` and `revert` reset it to the boundary, and all three
6
6
  // re-render the mirror from intent.md's parsed steps so it tracks reality without
7
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),
8
+ // fails a verb: the checkpoints ledger and intent.md stay the source of truth),
9
9
  // matching how `checkpoint`'s Log append behaves.
10
10
  import { readFileSync, writeFileSync } from 'node:fs';
11
11
  import { buildLogPath, intentPath } from "./sidecar.js";
12
12
  import { parseSteps } from "./orient.js";
13
13
  import { setCurrentStep, syncStepsSection } from "./buildlog.js";
14
- /** The Current-step label for no step in flight — the loop is at the step boundary. */
14
+ /** The Current-step label for no step in flight: the loop is at the step boundary. */
15
15
  export const AT_BOUNDARY = 'none (at the boundary)';
16
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.
17
+ * The current-step label for an open step: the number and title joined by an
18
+ * em dash, or a bare `<n>` when the step carries no title. `build` passes
19
+ * this; the boundary verbs pass AT_BOUNDARY.
20
20
  */
21
21
  export function stepLabel(n, title) {
22
22
  return title !== null && title.trim().length > 0 ? `${n} — ${title.trim()}` : String(n);
@@ -26,7 +26,7 @@ export function stepLabel(n, title) {
26
26
  * `label` and re-render the `## Steps` mirror from intent.md.
27
27
  *
28
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.
29
+ * thing swallows any IO error: the ledger must never block the verb.
30
30
  */
31
31
  export function syncBuildLogState(root, slug, label) {
32
32
  try {
package/dist/lib/check.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // pointers, and an all-slots-skipped run REFUSES rather than green-lighting
12
12
  // a repo checkride can't see (a vacuous pass is not a pass).
13
13
  // Checkride's stream discipline holds: human progress goes to stderr. Exit 0 is
14
- // green, 1 is red, 2 means the harness itself broke — reported distinctly,
14
+ // green, 1 is red, 2 means the harness itself broke: reported distinctly,
15
15
  // because a misconfigured gate must not read as broken code (both still block).
16
16
  import { spawnSync } from 'node:child_process';
17
17
  import { runChecks, runDoctor } from 'checkride';
@@ -21,7 +21,7 @@ import { resolveString } from "./settings.js";
21
21
  * Probe whether the check gate would have anything to run in `root`.
22
22
  *
23
23
  * A configured `check` command answers yes on its own; otherwise checkride's
24
- * doctor detection runs in-process — the same pass `plumbbob doctor` reports
24
+ * doctor detection runs in-process: the same pass `plumbbob doctor` reports
25
25
  * as a table. `start` reads this to surface "nothing to check" at plan time,
26
26
  * while the human is still deciding, instead of at the first refused
27
27
  * checkpoint.
@@ -37,19 +37,19 @@ export async function detectGate(root) {
37
37
  }
38
38
  catch {
39
39
  // A broken checkride harness is a different problem with its own reporters
40
- // (doctor's ✗ row, runCheck's exit 2) — the plan-time probe stays quiet
40
+ // (doctor's ✗ row, runCheck's exit 2); the plan-time probe stays quiet
41
41
  // rather than mislabeling it "nothing to check".
42
42
  return { configured: null, detected: true };
43
43
  }
44
44
  }
45
45
  /**
46
46
  * Checkride's always-on repo checks: the adapters that resolve with no tool
47
- * config of their own — the built-in `links` slot, the pnpm-audit security
47
+ * config of their own: the built-in `links` slot, the pnpm-audit security
48
48
  * scan, the publint/attw package-shape probes, and the pack/smoke/snippets
49
49
  * publish-bundle slots. Several are named for their shape rather than because
50
50
  * a bare directory reports them: `publint`/`attw` resolve off a devDependency,
51
- * `build` off a `scripts.build` entry — which says the package can be built,
52
- * not that checkride can see the code that goes into it — and `snippets`
51
+ * `build` off a `scripts.build` entry (which says the package can be built,
52
+ * not that checkride can see the code that goes into it), and `snippets`
53
53
  * carries two adapters since checkride 0.10.2, so the dist-facing
54
54
  * `snippets-dist` is named beside it (this list matches ADAPTER names, not
55
55
  * slot names). Every one of them but `links` is an opt-in slot, so a default
@@ -72,7 +72,7 @@ const ALWAYS_ON_ADAPTERS = [
72
72
  ];
73
73
  /**
74
74
  * The detection rule, shared by `start`'s probe and doctor's callout: some
75
- * tool slot beyond the always-on family has an adapter — i.e. checkride can
75
+ * tool slot beyond the always-on family has an adapter: that is, checkride can
76
76
  * see the CODE (a tsconfig, a test runner, a linter…), not just the repo.
77
77
  */
78
78
  export function gateDetectsTools(checks) {
@@ -90,7 +90,7 @@ export function gateDetectsTools(checks) {
90
90
  */
91
91
  export async function runCheck(root, flags = {}, commandFlag) {
92
92
  if (gateIsRunningFor(root)) {
93
- // This repo re-entering its OWN gate is never a slow gate — it is a
93
+ // This repo re-entering its OWN gate is never a slow gate: it is a
94
94
  // recursion whose next generation forks wider than this one. Exit 2 (the
95
95
  // harness broke), not 1: nothing has been learned about the code. A gate on
96
96
  // a DIFFERENT root is ordinary nested work (the test suite gating its
@@ -105,7 +105,7 @@ export async function runCheck(root, flags = {}, commandFlag) {
105
105
  }
106
106
  /**
107
107
  * The spawn override: stream the configured command's own output to the
108
- * terminal and return its exit code — the command is trusted verbatim, no
108
+ * terminal and return its exit code: the command is trusted verbatim, no
109
109
  * interpretation.
110
110
  */
111
111
  function runCommand(root, command, flags) {
@@ -133,7 +133,7 @@ async function runCheckride(root, flags) {
133
133
  include: flags.include !== undefined ? [...flags.include] : null,
134
134
  // Checkride's own vacuous-green refusal (0.10.2): zero checks executed is
135
135
  // an error, not a pass. It overlaps the refusal below and never reaches
136
- // the human — our predicate is the broader one (it also catches a
136
+ // the human: our predicate is the broader one (it also catches a
137
137
  // links-only run, where a check DID execute) and fires first, so
138
138
  // plumbbob's message and its exit 1 are what a reader sees. Kept anyway:
139
139
  // it is the contract checkride asks a gate to run under, so a future
@@ -145,7 +145,7 @@ async function runCheckride(root, flags) {
145
145
  }
146
146
  catch (err) {
147
147
  // The harness broke, not the code: a malformed checkride.config.json or
148
- // the like. Checkride's own CLI maps this to exit 2; so do we — a
148
+ // the like. Checkride's own CLI maps this to exit 2; so do we: a
149
149
  // misconfigured gate must report distinctly from red.
150
150
  const message = err instanceof Error ? err.message : String(err);
151
151
  process.stderr.write(`plumbbob: the check gate itself failed — ${message}\n` +
@@ -156,7 +156,7 @@ async function runCheckride(root, flags) {
156
156
  // Zero-config checkride skips slots with no detected tool; a repo it can't
157
157
  // see must refuse, not vacuously green-light the checkpoint. The built-in
158
158
  // `links` slot is always-on (empty detect list), so it alone proves
159
- // nothing about the code — a links-only run is still vacuous.
159
+ // nothing about the code: a links-only run is still vacuous.
160
160
  process.stderr.write('plumbbob: checkride found nothing to check in this repo — refusing to call that green.\n' +
161
161
  ' Add tool configs (tsconfig, vitest, …) or a checkride.config.json custom check,\n' +
162
162
  ' or set the "check" key in .plumbbob/settings.json to gate through another command.\n');
@@ -171,7 +171,7 @@ async function runCheckride(root, flags) {
171
171
  * Name the failing slots and where their raw diagnostics landed, so the agent
172
172
  * (verify) reads the tool's own JSON instead of scraping scrollback.
173
173
  *
174
- * The summary is the canonical pointer — `output_file` is the adapter's
174
+ * The summary is the canonical pointer: `output_file` is the adapter's
175
175
  * preferred name, but non-JSON output falls back to `.check/<slot>.stdout.txt`.
176
176
  */
177
177
  function reportFailingSlots(summary) {
@@ -184,7 +184,7 @@ function reportFailingSlots(summary) {
184
184
  process.stderr.write(`plumbbob: failing slots:\n${lines.join('\n')}\n Full report: .check/summary.json\n`);
185
185
  }
186
186
  /**
187
- * True when any narrowing flag was passed — the trigger for the spawn path's
187
+ * True when any narrowing flag was passed: the trigger for the spawn path's
188
188
  * flags-are-ignored warning.
189
189
  */
190
190
  function hasFlags(flags) {
@@ -0,0 +1,72 @@
1
+ // The `--body` stdin reader shared by `checkpoint` and `finish`: both verbs
2
+ // take a commit body on fd 0 via a single-quoted heredoc (`--body <<'BODY'`),
3
+ // never as an argument value. `evaluateCommitBody` is the pure decision over
4
+ // an already-known fd-0 shape, so every shape is a direct unit test with no
5
+ // fd tricks; `readCommitBody` gathers the real one: the mirror of latch.ts's
6
+ // evaluateLatch/checkLatch split.
7
+ //
8
+ // Four fd-0 shapes reach this in practice: a heredoc is a regular file, a
9
+ // pipe is a FIFO, an interactive terminal is a TTY, and an agent harness
10
+ // hands the CLI a socket. The first three already degrade safely: a TTY
11
+ // skips the read (it would never send EOF), a regular file/FIFO/`/dev/null`
12
+ // read to EOF and fall back to the caller's own default body when empty. A
13
+ // socket also never sends EOF on its own, so the same blind `readFileSync(0)`
14
+ // blocks forever and, worse, drops the requested body silently. Refuse
15
+ // instead: a silently dropped body reads as success.
16
+ import { fstatSync, readFileSync } from 'node:fs';
17
+ /**
18
+ * The refusal shown when `--body` is asked to read a socket: names the form
19
+ * that works so the retry succeeds instead of guessing again.
20
+ */
21
+ export const SOCKET_REFUSAL = "plumbbob: --body refuses — stdin is a socket, and a socket never sends EOF the way a heredoc does, so the read would block forever and the body would be lost. Redirect it in with the `--body <<'BODY'` … `BODY` heredoc form instead.\n";
22
+ /**
23
+ * Decide the `--body` outcome from an already-known fd-0 shape.
24
+ *
25
+ * Absent flag or a TTY both mean "don't read": the caller's fallback body
26
+ * applies. `other` covers a heredoc (regular file), a pipe (FIFO), and
27
+ * `/dev/null` (character device) alike: each is read to EOF, and an empty or
28
+ * failed read degrades to the same fallback an absent `--body` would use.
29
+ */
30
+ export function evaluateCommitBody(hasBodyFlag, shape, read) {
31
+ if (!hasBodyFlag || shape === 'tty') {
32
+ return { ok: true, body: null };
33
+ }
34
+ if (shape === 'socket') {
35
+ return { ok: false, message: SOCKET_REFUSAL };
36
+ }
37
+ try {
38
+ const raw = read().trimEnd();
39
+ return { ok: true, body: raw.length > 0 ? raw : null };
40
+ }
41
+ catch {
42
+ return { ok: true, body: null };
43
+ }
44
+ }
45
+ /**
46
+ * Read the `--body` commit body from the real fd 0, or refuse when it can
47
+ * never deliver one.
48
+ *
49
+ * `checkpoint` and `finish` both call this where each used to call its own
50
+ * copy of `bodyArg`: one implementation, one refusal string, so the two
51
+ * verbs cannot drift apart on the wording.
52
+ */
53
+ export function readCommitBody(args) {
54
+ return evaluateCommitBody(args.includes('--body'), stdinShape(), () => readFileSync(0, 'utf8'));
55
+ }
56
+ /**
57
+ * The fd-0 shape the guard cares about: a TTY skips the read (never sends
58
+ * EOF), a socket refuses (the same problem, but silent), everything else is
59
+ * read as before. An unreadable fd (no stdin attached at all) is treated the
60
+ * same as any other shape; the read attempt that follows fails safely.
61
+ */
62
+ function stdinShape() {
63
+ if (process.stdin.isTTY === true) {
64
+ return 'tty';
65
+ }
66
+ try {
67
+ return fstatSync(0).isSocket() ? 'socket' : 'other';
68
+ }
69
+ catch {
70
+ return 'other';
71
+ }
72
+ }
@@ -1,8 +1,8 @@
1
1
  // Conventional-Commit subject assembly. 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
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
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
5
+ // which `withMarker` prepends, so `git log --grep plumbbob` finds every plumbbob
6
6
  // commit. Functional and procedural, node builtins only, no gate: these only
7
7
  // shape a string the CLI is about to commit.
8
8
  /**
@@ -10,7 +10,7 @@
10
10
  * prefix on a step title.
11
11
  *
12
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
13
+ * cache` from being misread as a `Note` type; only these words, immediately
14
14
  * followed by an optional `(scope)`, an optional `!`, and a colon, count as an
15
15
  * author-supplied prefix.
16
16
  */
@@ -23,7 +23,7 @@ const PREFIX = new RegExp(`^(${TYPES.join('|')})(?:\\(([^)]+)\\))?(!)?:\\s+(.+)$
23
23
  *
24
24
  * `feat(escape-hatch): add the flag` → { type:'feat', scope:'escape-hatch',
25
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
26
+ * prefix comes back whole as the description with a null type/scope; the
27
27
  * caller then defaults the type and fills the scope from the build.
28
28
  */
29
29
  export function parseConventionalTitle(title) {
@@ -46,11 +46,11 @@ export function conventionalSubject(parts) {
46
46
  /**
47
47
  * A Conventional subject from a step title, filling gaps from the build.
48
48
  *
49
- * An author-written prefix (`fix(parser): …`) is honoured verbatim — its type,
49
+ * An author-written prefix (`fix(parser): …`) is honoured verbatim: its type,
50
50
  * scope, breaking marker, and exact wording all win. A bare prose title
51
51
  * defaults to `defaultType` and the build `scope`, and gets its sentence-case
52
52
  * opener de-capitalised (`Add the flag` → `add the flag`) to match
53
- * Conventional style — while an all-caps acronym start (`API`, `OAuth`) is
53
+ * Conventional style, while an all-caps acronym start (`API`, `OAuth`) is
54
54
  * left alone.
55
55
  */
56
56
  export function subjectFromTitle(title, defaultType, scope) {
package/dist/lib/git.js CHANGED
@@ -1,15 +1,15 @@
1
- // Thin git wrapper over `node:child_process` — the CLI stays on node builtins
1
+ // Thin git wrapper over `node:child_process`: the CLI stays on node builtins
2
2
  // plus a small deliberate dependency allowlist, and subprocess spawning is
3
3
  // centralized in files like this one. Plumbbob's git footprint is additive by
4
4
  // rule: these helpers read, locate, stage, and commit forward, and reset
5
- // `--hard` only to plumbbob's own recorded checkpoint SHAs — never a
5
+ // `--hard` only to plumbbob's own recorded checkpoint SHAs: never a
6
6
  // history-rewriting operation on anything pushed.
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { isAbsolute, join } from 'node:path';
9
9
  /**
10
10
  * Run git in `root` and return its trimmed stdout.
11
11
  *
12
- * stderr is discarded and a non-zero exit throws — callers that tolerate
12
+ * stderr is discarded and a non-zero exit throws: callers that tolerate
13
13
  * failure wrap the call in their own try/catch.
14
14
  */
15
15
  function runGit(root, args) {
@@ -35,8 +35,8 @@ export function findRepoRoot(cwd) {
35
35
  * Plumbbob writes its control-plane excludes to the shared gitdir's
36
36
  * `info/exclude` (personal machinery, never the repo's `.gitignore`), and
37
37
  * `--git-path` maps common-dir entries like `info/exclude` to the shared file
38
- * even from a linked worktree — whose per-worktree gitdir has no `info/` and
39
- * which git never reads for excludes. The result is relative to `root` unless
38
+ * even from a linked worktree (whose per-worktree gitdir has no `info/` and
39
+ * which git never reads for excludes). The result is relative to `root` unless
40
40
  * already absolute.
41
41
  */
42
42
  export function gitPath(root, relative) {
@@ -68,16 +68,16 @@ export function isDirty(root) {
68
68
  return runGit(root, ['status', '--porcelain']).length > 0;
69
69
  }
70
70
  /**
71
- * The number of commits on HEAD not reachable from `sha` — `git rev-list
71
+ * The number of commits on HEAD not reachable from `sha`: `git rev-list
72
72
  * --count --first-parent <sha>..HEAD`.
73
73
  *
74
74
  * `status` reads this for its receipts line: one neutral note when commits
75
75
  * landed since the last checkpoint outside plumbbob's ledger (out-of-band
76
76
  * commits are surfaced, never blocked). `--first-parent` keeps the count on
77
77
  * the branch's own line: merging upstream reads as the one merge commit, not
78
- * the dozens it carried — those didn't land "outside the ledger" in any sense
78
+ * the dozens it carried; those didn't land "outside the ledger" in any sense
79
79
  * the receipt should nag about. Best-effort and never throws: 0 when the range
80
- * is empty, `sha` is unknown to the repo, or HEAD is unborn — the count is
80
+ * is empty, `sha` is unknown to the repo, or HEAD is unborn: the count is
81
81
  * informational (the human commits freely), never a gate.
82
82
  */
83
83
  export function commitsSince(root, sha) {
@@ -92,22 +92,51 @@ export function commitsSince(root, sha) {
92
92
  // --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
93
93
  // only: stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
94
94
  /**
95
- * Stage everything (`git add -A`) — the checkpoint sweep that carries the work
95
+ * Stage everything (`git add -A`): the checkpoint sweep that carries the work
96
96
  * plus plumbbob's own bookkeeping into one commit.
97
97
  */
98
98
  export function stageAll(root) {
99
99
  runGit(root, ['add', '-A']);
100
100
  }
101
101
  /**
102
- * Stage a single path (vs `stageAll`'s `-A`).
102
+ * Stage a single path (vs `stageAll`'s `-A`), returning whether it staged.
103
103
  *
104
104
  * The plan-approval commit stages only the build's artifact folder so the
105
- * first step's diff can't absorb the plan scaffold — plan approval gets its
106
- * own commit. `path` may be absolute or repo-relative — git resolves it
105
+ * first step's diff can't absorb the plan scaffold: plan approval gets its
106
+ * own commit. `path` may be absolute or repo-relative; git resolves it
107
107
  * against `root`. The `--` guards a path that could look like a flag.
108
+ *
109
+ * A repo that gitignores the sidecar has decided not to track it, and git
110
+ * hard-refuses an explicit `git add` of an ignored path, so probe
111
+ * `check-ignore` first: an ignored path is skipped and returns false (the
112
+ * caller's commit is then record-only), a tracked one stages and returns true.
113
+ * Never `git add -f`: honoring the repo's exclusion is the whole point.
108
114
  */
109
115
  export function stagePath(root, path) {
116
+ if (isIgnored(root, path)) {
117
+ return false;
118
+ }
110
119
  runGit(root, ['add', '--', path]);
120
+ return true;
121
+ }
122
+ /**
123
+ * Whether git ignores `path`: a `git check-ignore -q` probe.
124
+ *
125
+ * Exit 0 means ignored; exit 1 means not (execFileSync throws on the non-zero,
126
+ * caught and read as false); any other status (128, a genuine failure)
127
+ * propagates. `path` may be absolute or repo-relative.
128
+ */
129
+ export function isIgnored(root, path) {
130
+ try {
131
+ runGit(root, ['check-ignore', '-q', '--', path]);
132
+ return true;
133
+ }
134
+ catch (error) {
135
+ if (error.status === 1) {
136
+ return false;
137
+ }
138
+ throw error;
139
+ }
111
140
  }
112
141
  /**
113
142
  * The repo-relative paths of non-ignored untracked files.
@@ -130,7 +159,7 @@ export function commit(root, subject, body) {
130
159
  return headSha(root);
131
160
  }
132
161
  /**
133
- * The `--stat` summary of what is currently staged (vs HEAD) — the diffstat
162
+ * The `--stat` summary of what is currently staged (vs HEAD): the diffstat
134
163
  * the deterministic checkpoint body carries when no `--body` prose arrives.
135
164
  * Empty when nothing is staged.
136
165
  */
@@ -138,7 +167,7 @@ export function stagedStat(root) {
138
167
  return runGit(root, ['diff', '--cached', '--stat']);
139
168
  }
140
169
  /**
141
- * The repo-relative paths currently staged (vs HEAD) — the set `checkpoint`
170
+ * The repo-relative paths currently staged (vs HEAD): the set `checkpoint`
142
171
  * checks against the step's seam (its granted edit paths) to warn about scope
143
172
  * drift. Empty when nothing is staged.
144
173
  */
@@ -147,7 +176,7 @@ export function stagedPaths(root) {
147
176
  return out.length === 0 ? [] : out.split('\n');
148
177
  }
149
178
  /**
150
- * Reset the worktree hard to `sha` — only ever one of plumbbob's own recorded
179
+ * Reset the worktree hard to `sha`: only ever one of plumbbob's own recorded
151
180
  * checkpoint SHAs, and only `revert` imports this (an ast-grep rule pins that
152
181
  * single importer).
153
182
  */
@@ -1,16 +1,16 @@
1
- // The intent.md parser. intent.md is a build's plan document — title, the
1
+ // The intent.md parser. intent.md is a build's plan document: title, the
2
2
  // numbered increments under `## Steps`, decisions, constraints, open questions.
3
3
  // This module reads it in two registers. The seam parse is strict by design: a
4
- // seam is a step's edit grant — the backtick-wrapped, repo-relative paths on
5
- // its single `seam:` sub-line — and it gates git behavior, so the parse refuses
4
+ // seam is a step's edit grant (the backtick-wrapped, repo-relative paths on
5
+ // its single `seam:` sub-line) and it gates git behavior, so the parse refuses
6
6
  // precisely on globs, absolute paths, a missing step, a missing seam, or more
7
7
  // than one seam line, rather than guessing. The best-effort scrapers below
8
8
  // (title, done-when, decisions, constraints) feed an agent's context, not a
9
- // gate, so they never refuse — they return what they can and report the lines
9
+ // gate, so they never refuse; they return what they can and report the lines
10
10
  // they skipped.
11
11
  const GLOB_CHARS = /[*?[\]{}]/;
12
12
  /**
13
- * The lines belonging to step `n` under `## Steps` — from its `N. ` opener to
13
+ * The lines belonging to step `n` under `## Steps`: from its `N. ` opener to
14
14
  * the next opener or the section end.
15
15
  *
16
16
  * Shared by the strict seam parse and the best-effort meta scrape; it keeps the
@@ -44,12 +44,12 @@ function sliceStep(content, step) {
44
44
  return { ok: true, itemLines: lines.slice(itemStart, itemEnd) };
45
45
  }
46
46
  /**
47
- * Parse step `n`'s seam — the backtick-wrapped paths on its single `seam:` sub-line.
47
+ * Parse step `n`'s seam: the backtick-wrapped paths on its single `seam:` sub-line.
48
48
  *
49
49
  * Strict: the seam is the step's edit grant and gates git behavior, so this
50
50
  * refuses on a missing step, a missing or duplicated seam line, an empty token,
51
51
  * a glob, or an absolute path rather than guessing. Tokens are exact
52
- * repo-relative paths or `dir/` prefix grants — never globs.
52
+ * repo-relative paths or `dir/` prefix grants: never globs.
53
53
  */
54
54
  export function parseStepSeam(content, step) {
55
55
  const slice = sliceStep(content, step);
@@ -132,7 +132,7 @@ export function isArtifactPath(relPath) {
132
132
  }
133
133
  /**
134
134
  * The staged paths that fall outside the step's seam AND outside the artifact
135
- * plane — the scope-drift set `checkpoint` warns about.
135
+ * plane: the scope-drift set `checkpoint` warns about.
136
136
  *
137
137
  * Guidance, not a gate: the checkpoint still commits them. An empty seam yields
138
138
  * no drift, so callers that cannot resolve a seam simply skip the warning
@@ -171,15 +171,15 @@ export function parseStepMeta(content, step) {
171
171
  const body = collected.join(' ').replace(/^\d+\.\s*(?:\[[ xX]\]\s*)?/, '').trim();
172
172
  const m = DONE_WHEN.exec(body);
173
173
  if (m === null) {
174
- return { title: stripTrailingDash(body), doneWhen: '' };
174
+ return { title: stripTrailingSeparator(body), doneWhen: '' };
175
175
  }
176
176
  return {
177
- title: stripTrailingDash(body.slice(0, m.index)),
177
+ title: stripTrailingSeparator(body.slice(0, m.index)),
178
178
  doneWhen: body.slice(m.index + m[0].length).trim(),
179
179
  };
180
180
  }
181
181
  /**
182
- * The build title — the first `# ` heading. '' when absent.
182
+ * The build title: the first `# ` heading. '' when absent.
183
183
  */
184
184
  export function parseBuildTitle(content) {
185
185
  for (const line of content.split('\n')) {
@@ -193,12 +193,12 @@ export function parseBuildTitle(content) {
193
193
  const SCOPE_HEADER = /^\*\*Scope:\*\*\s*(.*)$/;
194
194
  const SCOPE_PLACEHOLDER = /^<.*>$/;
195
195
  /**
196
- * The build's default Conventional-Commit scope — the `**Scope:**` header field
196
+ * The build's default Conventional-Commit scope: the `**Scope:**` header field
197
197
  * authored once in intent.md at plan time.
198
198
  *
199
199
  * The value is read verbatim off the line, with any trailing `<!-- ... -->`
200
- * guidance note stripped. An unfilled placeholder — an empty value or an
201
- * angle-bracket placeholder like `<feature>` — parses as absent, so a freshly
200
+ * guidance note stripped. An unfilled placeholder (an empty value or an
201
+ * angle-bracket placeholder like `<feature>`) parses as absent, so a freshly
202
202
  * scaffolded, not-yet-edited build falls through to the next rung of the scope
203
203
  * fallback chain (the build slug) rather than landing commits scoped
204
204
  * `(<feature>)`. null when the header is missing entirely, so an intent.md
@@ -221,7 +221,7 @@ export function parseBuildScope(content) {
221
221
  *
222
222
  * Verbatim keeps a bullet's `*because*` rationale intact for the agent that
223
223
  * reads it. `skipped` holds any line under the heading that was neither a
224
- * bullet, a continuation, nor blank — a malformed bullet the caller warns
224
+ * bullet, a continuation, nor blank: a malformed bullet the caller warns
225
225
  * about rather than dropping silently.
226
226
  */
227
227
  export function scrapeBullets(content, heading) {
@@ -264,11 +264,12 @@ export function scrapeBullets(content, heading) {
264
264
  return { items, skipped };
265
265
  }
266
266
  /**
267
- * Trim a trailing em-dash or hyphen (the `Title —` separator) plus surrounding
268
- * space, so a title never carries the marker that introduced its done-when.
267
+ * Trim a trailing separator (a comma, or the legacy em dash or hyphen) plus
268
+ * surrounding space, so a title never carries the marker that introduced its
269
+ * done-when.
269
270
  */
270
- function stripTrailingDash(text) {
271
- return text.replace(/\s*[—-]+\s*$/, '').trim();
271
+ function stripTrailingSeparator(text) {
272
+ return text.replace(/\s*[—,-]+\s*$/, '').trim();
272
273
  }
273
274
  /**
274
275
  * Shorthand for a failed SeamParse.