plumbbob 0.8.18 → 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 +50 -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 +86 -45
  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 +1 -1
  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 +3 -3
  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
package/dist/lib/git.js CHANGED
@@ -1,15 +1,26 @@
1
- // Thin git wrapper over `node:child_process` (C2: node builtins plus the
2
- // deliberate few). Functional/procedural, no classes (C1). Plumbbob's git
3
- // footprint is additive (C5); these helpers only read and locate.
1
+ // Thin git wrapper over `node:child_process` — the CLI stays on node builtins
2
+ // plus a small deliberate dependency allowlist, and subprocess spawning is
3
+ // centralized in files like this one. Plumbbob's git footprint is additive by
4
+ // rule: these helpers read, locate, stage, and commit forward, and reset
5
+ // `--hard` only to plumbbob's own recorded checkpoint SHAs — never a
6
+ // history-rewriting operation on anything pushed.
4
7
  import { execFileSync } from 'node:child_process';
5
8
  import { isAbsolute, join } from 'node:path';
9
+ /**
10
+ * Run git in `root` and return its trimmed stdout.
11
+ *
12
+ * stderr is discarded and a non-zero exit throws — callers that tolerate
13
+ * failure wrap the call in their own try/catch.
14
+ */
6
15
  function runGit(root, args) {
7
16
  return execFileSync('git', ['-C', root, ...args], {
8
17
  encoding: 'utf8',
9
18
  stdio: ['ignore', 'pipe', 'ignore'],
10
19
  }).trim();
11
20
  }
12
- // The git toplevel for `cwd`, or null when `cwd` is not inside a git repo.
21
+ /**
22
+ * The git toplevel for `cwd`, or null when `cwd` is not inside a git repo.
23
+ */
13
24
  export function findRepoRoot(cwd) {
14
25
  try {
15
26
  return runGit(cwd, ['rev-parse', '--show-toplevel']);
@@ -18,17 +29,29 @@ export function findRepoRoot(cwd) {
18
29
  return null;
19
30
  }
20
31
  }
21
- // Absolute path to a file inside the repo's *common* git dir. `--git-path` maps
22
- // common-dir entries like `info/exclude` to the shared file even from a linked
23
- // worktree, whose per-worktree gitdir has no `info/` and which git never reads
24
- // for excludes (D33). The result is relative to `root` unless already absolute.
32
+ /**
33
+ * Absolute path to a file inside the repo's *common* git dir.
34
+ *
35
+ * Plumbbob writes its control-plane excludes to the shared gitdir's
36
+ * `info/exclude` (personal machinery, never the repo's `.gitignore`), and
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
40
+ * already absolute.
41
+ */
25
42
  export function gitPath(root, relative) {
26
43
  const out = runGit(root, ['rev-parse', '--git-path', relative]);
27
44
  return isAbsolute(out) ? out : join(root, out);
28
45
  }
46
+ /**
47
+ * The SHA of the current HEAD commit.
48
+ */
29
49
  export function headSha(root) {
30
50
  return runGit(root, ['rev-parse', 'HEAD']);
31
51
  }
52
+ /**
53
+ * True when the repo has at least one commit (HEAD resolves).
54
+ */
32
55
  export function hasCommit(root) {
33
56
  try {
34
57
  runGit(root, ['rev-parse', '--verify', 'HEAD']);
@@ -38,18 +61,25 @@ export function hasCommit(root) {
38
61
  return false;
39
62
  }
40
63
  }
41
- // Dirty = any tracked change or non-ignored untracked file.
64
+ /**
65
+ * Dirty = any tracked change or non-ignored untracked file.
66
+ */
42
67
  export function isDirty(root) {
43
68
  return runGit(root, ['status', '--porcelain']).length > 0;
44
69
  }
45
- // The number of commits on HEAD not reachable from `sha` — `git rev-list --count
46
- // --first-parent <sha>..HEAD`. The receipts line (D66) reads this to surface commits
47
- // that landed since the last checkpoint outside plumbbob's ledger. `--first-parent`
48
- // keeps the count on the branch's own line: merging upstream reads as the one merge
49
- // commit, not the dozens it carried — those didn't land "outside the ledger" in any
50
- // sense the receipt should nag about. Best-effort and never throws: 0 when the range
51
- // is empty, `sha` is unknown to the repo, or HEAD is unborn — the count is
52
- // informational (the human commits freely, C5), never a gate.
70
+ /**
71
+ * The number of commits on HEAD not reachable from `sha` — `git rev-list
72
+ * --count --first-parent <sha>..HEAD`.
73
+ *
74
+ * `status` reads this for its receipts line: one neutral note when commits
75
+ * landed since the last checkpoint outside plumbbob's ledger (out-of-band
76
+ * commits are surfaced, never blocked). `--first-parent` keeps the count on
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
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
81
+ * informational (the human commits freely), never a gate.
82
+ */
53
83
  export function commitsSince(root, sha) {
54
84
  try {
55
85
  const n = Number.parseInt(runGit(root, ['rev-list', '--count', '--first-parent', `${sha}..HEAD`]), 10);
@@ -60,42 +90,67 @@ export function commitsSince(root, sha) {
60
90
  }
61
91
  }
62
92
  // --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
63
- // only (C5): stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
93
+ // only: stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
94
+ /**
95
+ * Stage everything (`git add -A`) — the checkpoint sweep that carries the work
96
+ * plus plumbbob's own bookkeeping into one commit.
97
+ */
64
98
  export function stageAll(root) {
65
99
  runGit(root, ['add', '-A']);
66
100
  }
67
- // Stage a single path (vs `stageAll`'s `-A`): the plan-approval commit stages only
68
- // the build's artifact folder so the first step's diff can't absorb the plan
69
- // scaffold (D36). `path` may be absolute or repo-relative — git resolves it against
70
- // `root`. The `--` guards a path that could look like a flag.
101
+ /**
102
+ * Stage a single path (vs `stageAll`'s `-A`).
103
+ *
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
107
+ * against `root`. The `--` guards a path that could look like a flag.
108
+ */
71
109
  export function stagePath(root, path) {
72
110
  runGit(root, ['add', '--', path]);
73
111
  }
112
+ /**
113
+ * The repo-relative paths of non-ignored untracked files.
114
+ */
74
115
  export function untrackedPaths(root) {
75
116
  const out = runGit(root, ['ls-files', '--others', '--exclude-standard']);
76
117
  return out.length === 0 ? [] : out.split('\n');
77
118
  }
78
- // Commit whatever is staged as a checkpoint and return its SHA. --allow-empty so
79
- // a step that touched only ignored files still gets a checkpoint to revert to.
80
- // An optional `body` becomes the commit message body: git joins the two `-m`
81
- // paragraphs with a blank line, so the subject stays the first line (D34).
119
+ /**
120
+ * Commit whatever is staged as a checkpoint and return its SHA.
121
+ *
122
+ * `--allow-empty` so a step that touched only ignored files still gets a
123
+ * checkpoint to revert to. An optional `body` becomes the commit message body:
124
+ * git joins the two `-m` paragraphs with a blank line, so the CLI-owned
125
+ * subject stays the first line and the body rides beneath it.
126
+ */
82
127
  export function commit(root, subject, body) {
83
128
  const message = body ? ['-m', subject, '-m', body] : ['-m', subject];
84
129
  runGit(root, ['commit', '--allow-empty', ...message]);
85
130
  return headSha(root);
86
131
  }
87
- // The `--stat` summary of what is currently staged (vs HEAD) — the diffstat the
88
- // deterministic checkpoint body carries (D35). Empty when nothing is staged.
132
+ /**
133
+ * The `--stat` summary of what is currently staged (vs HEAD) — the diffstat
134
+ * the deterministic checkpoint body carries when no `--body` prose arrives.
135
+ * Empty when nothing is staged.
136
+ */
89
137
  export function stagedStat(root) {
90
138
  return runGit(root, ['diff', '--cached', '--stat']);
91
139
  }
92
- // The repo-relative paths currently staged (vs HEAD) — the set `checkpoint`
93
- // checks against the step's seam to warn about scope drift. Empty when nothing is
94
- // staged.
140
+ /**
141
+ * The repo-relative paths currently staged (vs HEAD) — the set `checkpoint`
142
+ * checks against the step's seam (its granted edit paths) to warn about scope
143
+ * drift. Empty when nothing is staged.
144
+ */
95
145
  export function stagedPaths(root) {
96
146
  const out = runGit(root, ['diff', '--cached', '--name-only']);
97
147
  return out.length === 0 ? [] : out.split('\n');
98
148
  }
149
+ /**
150
+ * Reset the worktree hard to `sha` — only ever one of plumbbob's own recorded
151
+ * checkpoint SHAs, and only `revert` imports this (an ast-grep rule pins that
152
+ * single importer).
153
+ */
99
154
  export function resetHard(root, sha) {
100
155
  runGit(root, ['reset', '--hard', sha]);
101
156
  }
@@ -1,11 +1,22 @@
1
- // The intent parser. `build <n>` reads the nth step under `## Steps` and extracts
2
- // its seam — the backtick-wrapped paths on the single `seam:` sub-line. The seam
3
- // half is strict by design (it gates git behavior): it refuses precisely on globs,
4
- // absolute paths, a missing step, a missing seam, or more than one seam line,
5
- // rather than guessing. The best-effort scrapers below (title, done-when,
6
- // decisions, constraints) feed an agent's context, not a gate, so they never
7
- // refuse — they return what they can and report the lines they skipped (D61).
1
+ // The intent.md parser. intent.md is a build's plan document — title, the
2
+ // numbered increments under `## Steps`, decisions, constraints, open questions.
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
6
+ // precisely on globs, absolute paths, a missing step, a missing seam, or more
7
+ // than one seam line, rather than guessing. The best-effort scrapers below
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
10
+ // they skipped.
8
11
  const GLOB_CHARS = /[*?[\]{}]/;
12
+ /**
13
+ * The lines belonging to step `n` under `## Steps` — from its `N. ` opener to
14
+ * the next opener or the section end.
15
+ *
16
+ * Shared by the strict seam parse and the best-effort meta scrape; it keeps the
17
+ * "no Steps section" and "no such step" error strings distinct so both callers
18
+ * can tell them apart.
19
+ */
9
20
  function sliceStep(content, step) {
10
21
  const lines = content.split('\n');
11
22
  const stepsIdx = lines.findIndex((l) => l.trim() === '## Steps');
@@ -32,6 +43,14 @@ function sliceStep(content, step) {
32
43
  const itemEnd = nextStart === undefined ? sectionEnd : nextStart;
33
44
  return { ok: true, itemLines: lines.slice(itemStart, itemEnd) };
34
45
  }
46
+ /**
47
+ * Parse step `n`'s seam — the backtick-wrapped paths on its single `seam:` sub-line.
48
+ *
49
+ * Strict: the seam is the step's edit grant and gates git behavior, so this
50
+ * refuses on a missing step, a missing or duplicated seam line, an empty token,
51
+ * a glob, or an absolute path rather than guessing. Tokens are exact
52
+ * repo-relative paths or `dir/` prefix grants — never globs.
53
+ */
35
54
  export function parseStepSeam(content, step) {
36
55
  const slice = sliceStep(content, step);
37
56
  if (!slice.ok) {
@@ -52,7 +71,7 @@ export function parseStepSeam(content, step) {
52
71
  // Truncate each at an HTML-comment opener so a trailing `<!-- ... -->` note
53
72
  // (which may carry its own backticks) is never read as a seam token; a
54
73
  // continuation ends at the first line whose pre-comment text has no backtick
55
- // or that opens a new sub-bullet (a `- model:` note may carry backticks too, D62).
74
+ // or that opens a new sub-bullet (a `- model:` note may carry backticks too).
56
75
  const seamStart = seamLines[0] ?? 0;
57
76
  const decl = [];
58
77
  for (let i = seamStart; i < itemLines.length; i++) {
@@ -88,25 +107,37 @@ export function parseStepSeam(content, step) {
88
107
  }
89
108
  return { ok: true, seam };
90
109
  }
91
- // Seam membership (D23): a repo-relative path is in-seam if it equals an exact
92
- // token, or is prefixed by a `dir/` grant. Shared by `checkpoint` (scope-drift
93
- // warn) and `revert` (untracked cleanup).
110
+ /**
111
+ * Seam membership: a repo-relative path is in-seam if it equals an exact token,
112
+ * or is prefixed by a `dir/` grant.
113
+ *
114
+ * Shared by `checkpoint` (the scope-drift warning) and `revert` (untracked
115
+ * cleanup).
116
+ */
94
117
  export function matchesSeam(relPath, tokens) {
95
118
  return tokens.some((token) => (token.endsWith('/') ? relPath.startsWith(token) : relPath === token));
96
119
  }
97
- // Plumbbob's own artifact plane (D17): everything under `.plumbbob/` is plumbbob's
98
- // bookkeeping — the tracked intent/build-log/checkpoints that ride the branch, and
99
- // the excluded control markers. It is never the user's code, so it never counts as
100
- // scope drift, and `revert`'s untracked cleanup must never delete it. checkpoint
101
- // stages this plane itself on every tick (the `[x]` flip, the build-log line), so
102
- // without this whitelist every checkpoint would cry wolf about its own writes.
120
+ /**
121
+ * Whether a repo-relative path is plumbbob's own bookkeeping under `.plumbbob/`.
122
+ *
123
+ * That plane holds the tracked intent/build-log/checkpoints that ride the
124
+ * branch, plus the git-excluded control markers. It is never the user's code,
125
+ * so it never counts as scope drift, and `revert`'s untracked cleanup must
126
+ * never delete it. `checkpoint` stages this plane itself on every tick (the
127
+ * `[x]` flip, the build-log line), so without this whitelist every checkpoint
128
+ * would cry wolf about its own writes.
129
+ */
103
130
  export function isArtifactPath(relPath) {
104
131
  return relPath === '.plumbbob' || relPath.startsWith('.plumbbob/');
105
132
  }
106
- // The staged paths that fall outside the step's seam AND outside the artifact
107
- // plane — the scope-drift set `checkpoint` warns about (guidance, not a gate: the
108
- // checkpoint still commits them). An empty seam yields no drift, so callers that
109
- // cannot resolve a seam simply skip the warning rather than flagging everything.
133
+ /**
134
+ * The staged paths that fall outside the step's seam AND outside the artifact
135
+ * plane — the scope-drift set `checkpoint` warns about.
136
+ *
137
+ * Guidance, not a gate: the checkpoint still commits them. An empty seam yields
138
+ * no drift, so callers that cannot resolve a seam simply skip the warning
139
+ * rather than flagging everything.
140
+ */
110
141
  export function scopeDrift(paths, seam) {
111
142
  if (seam.length === 0) {
112
143
  return [];
@@ -114,6 +145,14 @@ export function scopeDrift(paths, seam) {
114
145
  return paths.filter((p) => !matchesSeam(p, seam) && !isArtifactPath(p));
115
146
  }
116
147
  const DONE_WHEN = /\*\*done when:\*\*/i;
148
+ /**
149
+ * Scrape step `n`'s title and done-when, best-effort.
150
+ *
151
+ * `title` is the text between the `N. [ ]` opener and the `**done when:**`
152
+ * marker; `doneWhen` is everything after it, with wrapped continuation lines
153
+ * joined until the first sub-bullet (the seam line). A missing section or step
154
+ * yields empty strings, never a refusal.
155
+ */
117
156
  export function parseStepMeta(content, step) {
118
157
  const slice = sliceStep(content, step);
119
158
  if (!slice.ok) {
@@ -139,7 +178,9 @@ export function parseStepMeta(content, step) {
139
178
  doneWhen: body.slice(m.index + m[0].length).trim(),
140
179
  };
141
180
  }
142
- // The build title — the first `# ` heading. '' when absent.
181
+ /**
182
+ * The build title — the first `# ` heading. '' when absent.
183
+ */
143
184
  export function parseBuildTitle(content) {
144
185
  for (const line of content.split('\n')) {
145
186
  const m = /^#\s+(.+)$/.exec(line);
@@ -151,13 +192,18 @@ export function parseBuildTitle(content) {
151
192
  }
152
193
  const SCOPE_HEADER = /^\*\*Scope:\*\*\s*(.*)$/;
153
194
  const SCOPE_PLACEHOLDER = /^<.*>$/;
154
- // The build's default Conventional-Commit scope (D4): the `**Scope:**` header
155
- // field authored once at plan time, its value read verbatim off the line with any
156
- // trailing `<!-- ... -->` guidance note stripped. An unfilled placeholder — an
157
- // empty value or an angle-bracket placeholder like `<feature>` — parses as absent
158
- // (D7), so a freshly scaffolded, not-yet-edited build falls through to the next
159
- // scope rung (the build slug) rather than landing commits scoped `(<feature>)`.
160
- // null when the header is absent entirely — the pre-D4 shape (C2 back-compat).
195
+ /**
196
+ * The build's default Conventional-Commit scope — the `**Scope:**` header field
197
+ * authored once in intent.md at plan time.
198
+ *
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
202
+ * scaffolded, not-yet-edited build falls through to the next rung of the scope
203
+ * fallback chain (the build slug) rather than landing commits scoped
204
+ * `(<feature>)`. null when the header is missing entirely, so an intent.md
205
+ * without one behaves the same way.
206
+ */
161
207
  export function parseBuildScope(content) {
162
208
  for (const line of content.split('\n')) {
163
209
  const m = SCOPE_HEADER.exec(line.trim());
@@ -169,6 +215,15 @@ export function parseBuildScope(content) {
169
215
  }
170
216
  return null;
171
217
  }
218
+ /**
219
+ * Scrape the top-level `- ` bullets under a `## Heading`, each verbatim with
220
+ * its wrapped continuation lines joined into one string.
221
+ *
222
+ * Verbatim keeps a bullet's `*because*` rationale intact for the agent that
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
225
+ * about rather than dropping silently.
226
+ */
172
227
  export function scrapeBullets(content, heading) {
173
228
  const lines = content.split('\n');
174
229
  const start = lines.findIndex((l) => l.trim() === heading);
@@ -208,11 +263,16 @@ export function scrapeBullets(content, heading) {
208
263
  flush();
209
264
  return { items, skipped };
210
265
  }
211
- // Trim a trailing em-dash or hyphen (the `Title —` separator) plus surrounding
212
- // space, so a title never carries the marker that introduced its done-when.
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.
269
+ */
213
270
  function stripTrailingDash(text) {
214
271
  return text.replace(/\s*[—-]+\s*$/, '').trim();
215
272
  }
273
+ /**
274
+ * Shorthand for a failed SeamParse.
275
+ */
216
276
  function fail(error) {
217
277
  return { ok: false, error };
218
278
  }
package/dist/lib/latch.js CHANGED
@@ -1,51 +1,70 @@
1
- // The approval latch (D64) — the ledger-plane predicate `checkpoint` evaluates
2
- // before its check gate (C4: cheap first; the gate already ran in the verify
3
- // tick). The work plane stays guidance (D10/D13 untouched); only the commit tick
4
- // is latched: a step may not land unless a human turn intervened since entry, the
5
- // human granted self-approval (a D65 one-turn grant from their literal prompt), or
6
- // the ledger is dormant. Five rows, evaluated in order, first hit wins; the refusal
7
- // message *is* the pause affordance. A standing `auto` in a settings file is NOT a
8
- // grant (D67): a model can write that file, and a grant it can forge is no grant —
9
- // so the latch reads it only to say so at the pause, never to allow. Reads
10
- // TURN/TICK/GRANT/isTTY only — never the host (C5). Named exports, node builtins.
1
+ // The approval latch — the ledger-plane predicate `checkpoint` evaluates before
2
+ // its check gate (the latch is cheap, so it runs first; the heavy gate already
3
+ // ran in the verify tick). The work plane stays guidance — no edit is ever
4
+ // blocked — but the commit tick is latched: a step may not land unless a human
5
+ // turn intervened since the step was entered, the human granted a one-turn
6
+ // self-approval from their literal prompt, or the turn ledger is dormant (a
7
+ // host with no hooks grows no ledger, and the latch stays out of the way rather
8
+ // than wedging). The ledger is a pair of flat sidecar control files: TURN
9
+ // counts human prompts (ticked by the `plumbbob turn` hook), TICK stamps that
10
+ // count when a step is entered — TURN > TICK means a human turn intervened.
11
+ // Five rows, evaluated in order, first hit wins; the refusal message *is* the
12
+ // pause affordance. A standing `auto` in a settings file is never a grant: a
13
+ // model can write that file, and a grant it can forge is no grant — the latch
14
+ // reads it only to say so at the pause, never to allow. It reads only
15
+ // TURN/TICK/GRANT and isTTY — it never sniffs the host session. Named exports,
16
+ // node builtins.
11
17
  import { readFileSync } from 'node:fs';
12
18
  import { grantPath, tickPath, turnPath } from "./sidecar.js";
13
19
  import { resolveBoolean } from "./settings.js";
14
20
  const ALLOW = { allow: true };
15
- // The refusal message is the affordance — it tells the model what the pause is,
16
- // how it ends (approval on the next turn lands it; `/pb-build` only starts the next
17
- // step), and names the only legitimate grants. The plan commit gets its own wording:
18
- // there is no step, no diff, and no self-review at plan time — what the human approves
19
- // is the plan itself.
21
+ /**
22
+ * The refusal message is the affordance — it tells the model what the pause is,
23
+ * how it ends (approval on the next turn lands it; `/plumbbob:build` only starts the
24
+ * next step), and names the only legitimate grants.
25
+ */
20
26
  const NO_TURN_MESSAGE = `plumbbob: checkpoint refused — no human turn since this step began. This is the
21
27
  pause: present the diff and the self-review, then end the turn. The human's approval on
22
28
  their next turn is what lets this land — re-run \`checkpoint\` then and stop at the
23
- boundary; \`/pb-build\` only starts the next step, it never lands this one. (An explicit
24
- \`/pb-build --auto\` or a step range in the human's own prompt is the only self-approval.)
29
+ boundary; \`/plumbbob:build\` only starts the next step, it never lands this one. (An explicit
30
+ \`/plumbbob:build --auto\` or a step range in the human's own prompt is the only self-approval.)
25
31
  `;
32
+ /**
33
+ * The plan commit gets its own wording: there is no step, no diff, and no
34
+ * self-review at plan time — what the human approves is the plan itself.
35
+ */
26
36
  const NO_TURN_PLAN_MESSAGE = `plumbbob: checkpoint refused — no human turn since \`start\` stamped this plan. This
27
37
  is the plan pause: present the plan, then end the turn; the human's approving
28
- message is the tick that lets it land on re-fire. (An explicit \`/pb-build --auto\` or a
38
+ message is the tick that lets it land on re-fire. (An explicit \`/plumbbob:build --auto\` or a
29
39
  step range in the human's own prompt is the only self-approval.)
30
40
  `;
31
- // D67: a settings `auto` is no longer honored — a model can write that file, so a
32
- // grant it can forge is no grant. When one is set we say so at the point of refusal,
33
- // rather than silently ignoring it, and point at the only self-approval that works.
34
- const SETTINGS_AUTO_NOTE = `note: \`auto\` is set in settings but is no longer a grant (D67) — re-fire \`/pb-build --auto\` to self-approve.
41
+ /**
42
+ * A settings `auto` is never a grant — a model can write that file, and a grant
43
+ * it can forge is no grant. When one is set, the refusal names it rather than
44
+ * silently ignoring it, and points at the only self-approval that works.
45
+ */
46
+ const SETTINGS_AUTO_NOTE = `note: \`auto\` is set in settings but is no longer a grant (D67) — re-fire \`/plumbbob:build --auto\` to self-approve.
35
47
  `;
48
+ /**
49
+ * The refusal for a range grant whose ceiling the landing step exceeds.
50
+ */
36
51
  function ceilingMessage(ceiling) {
37
52
  return `plumbbob: checkpoint refused — the range you granted ends at step ${ceiling} — pause here; re-fire to continue.\n`;
38
53
  }
39
- // The five-row matrix, in order, first hit wins. A `range` grant speaks only to
40
- // numbered steps: on a `--plan` checkpoint (step null) it neither allows nor
41
- // refuses, and the decision falls through to the turn comparison.
54
+ /**
55
+ * The five-row matrix, in order, first hit wins.
56
+ *
57
+ * A `range` grant speaks only to numbered steps: on a `--plan` checkpoint (step
58
+ * null) it neither allows nor refuses, and the decision falls through to the
59
+ * turn comparison.
60
+ */
42
61
  export function evaluateLatch(input) {
43
62
  if (input.isTTY)
44
63
  return ALLOW; // 1 — a human at the keyboard
45
64
  if (input.turn === null || input.tick === null)
46
- return ALLOW; // 2 — dormant / hand-built
65
+ return ALLOW; // 2 — dormant ledger / hand-built diff
47
66
  if (input.grant?.kind === 'auto')
48
- return ALLOW; // 3 — one-turn grant (D65)
67
+ return ALLOW; // 3 — one-turn grant the human typed
49
68
  if (input.grant?.kind === 'range' && input.step !== null) {
50
69
  if (input.step <= input.grant.ceiling)
51
70
  return ALLOW;
@@ -53,14 +72,17 @@ export function evaluateLatch(input) {
53
72
  }
54
73
  if (input.turn > input.tick)
55
74
  return ALLOW; // 4 — a human turn intervened
56
- // 5 — the pause (plan-worded when no step is landing). A settings `auto` no longer
57
- // allows (D67); when one is set, the refusal notes it rather than honoring it.
75
+ // 5 — the pause (plan-worded when no step is landing). A settings `auto` never
76
+ // allows; when one is set, the refusal notes it rather than honoring it.
58
77
  const base = input.step === null ? NO_TURN_PLAN_MESSAGE : NO_TURN_MESSAGE;
59
78
  return { allow: false, reason: 'no-turn', message: input.settingsAuto ? base + SETTINGS_AUTO_NOTE : base };
60
79
  }
61
- // Parse a GRANT file's content (`auto` | `range <M>`). Anything else — including
62
- // a corrupted or hand-mangled file — contributes nothing (D27): no grant, never
63
- // an error.
80
+ /**
81
+ * Parse a GRANT file's content (`auto` | `range <M>`).
82
+ *
83
+ * Anything else — including a corrupted or hand-mangled file — contributes
84
+ * nothing: no grant, never an error.
85
+ */
64
86
  export function parseGrant(raw) {
65
87
  const trimmed = raw.trim();
66
88
  if (trimmed === 'auto')
@@ -70,9 +92,12 @@ export function parseGrant(raw) {
70
92
  return { kind: 'range', ceiling: Number(range[1]) };
71
93
  return null;
72
94
  }
73
- // Gather the latch's inputs from the worktree and evaluate. `step` is the step
74
- // number being checkpointed, or null for the plan commit (which latches on the
75
- // TICK that `start` stamped).
95
+ /**
96
+ * Gather the latch's inputs from the worktree and evaluate.
97
+ *
98
+ * `step` is the step number being checkpointed, or null for the plan commit
99
+ * (which latches on the TICK that `start` stamped).
100
+ */
76
101
  export function checkLatch(root, step) {
77
102
  return evaluateLatch({
78
103
  isTTY: process.stdin.isTTY === true,
@@ -80,10 +105,13 @@ export function checkLatch(root, step) {
80
105
  tick: readCount(tickPath(root)),
81
106
  grant: readGrant(root),
82
107
  step,
83
- // Still read from the ladder — but only to decorate the pause (D67), not to allow.
108
+ // Read from the settings ladder — but only to decorate the pause, never to allow.
84
109
  settingsAuto: resolveBoolean(root, 'auto', false),
85
110
  });
86
111
  }
112
+ /**
113
+ * The current GRANT, or null when the file is absent or unparseable.
114
+ */
87
115
  function readGrant(root) {
88
116
  try {
89
117
  return parseGrant(readFileSync(grantPath(root), 'utf8'));
@@ -92,9 +120,12 @@ function readGrant(root) {
92
120
  return null;
93
121
  }
94
122
  }
95
- // A ledger count, or null when the file is absent or holds garbage — malformed
96
- // contributes nothing, and an unreadable ledger reads as dormant (row 2) rather
97
- // than wedging the land.
123
+ /**
124
+ * A ledger count, or null when the file is absent or holds garbage.
125
+ *
126
+ * Malformed contributes nothing, and an unreadable ledger reads as dormant
127
+ * (row 2) rather than wedging the land.
128
+ */
98
129
  function readCount(path) {
99
130
  try {
100
131
  const n = Number.parseInt(readFileSync(path, 'utf8').trim(), 10);