plumbbob 0.9.5 → 0.10.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 (144) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +89 -87
  3. package/dist/cli-core.js +18 -8
  4. package/dist/cli.js +1 -1
  5. package/dist/lib/agents.js +26 -26
  6. package/dist/lib/buildlog.js +21 -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 +70 -54
  18. package/dist/lib/templates.js +1 -1
  19. package/dist/verbs/abandon.js +122 -0
  20. package/dist/verbs/agent.js +25 -25
  21. package/dist/verbs/build.js +9 -9
  22. package/dist/verbs/check.js +6 -6
  23. package/dist/verbs/checkpoint.js +48 -47
  24. package/dist/verbs/doctor.js +35 -29
  25. package/dist/verbs/finish.js +27 -40
  26. package/dist/verbs/handoff.js +9 -8
  27. package/dist/verbs/init.js +9 -9
  28. package/dist/verbs/park.js +3 -3
  29. package/dist/verbs/recover.js +12 -12
  30. package/dist/verbs/revert.js +12 -12
  31. package/dist/verbs/spike.js +21 -21
  32. package/dist/verbs/start.js +49 -19
  33. package/dist/verbs/status.js +8 -8
  34. package/dist/verbs/turn.js +16 -16
  35. package/dist/verbs/use.js +4 -4
  36. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  37. package/node_modules/checkride/CHANGELOG.md +401 -0
  38. package/node_modules/checkride/README.md +27 -9
  39. package/node_modules/checkride/dist/adapters.d.ts +12 -4
  40. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  41. package/node_modules/checkride/dist/adapters.js +21 -1
  42. package/node_modules/checkride/dist/adapters.js.map +1 -1
  43. package/node_modules/checkride/dist/agent-setup/cursor.d.ts +9 -1
  44. package/node_modules/checkride/dist/agent-setup/cursor.d.ts.map +1 -1
  45. package/node_modules/checkride/dist/agent-setup/cursor.js +59 -1
  46. package/node_modules/checkride/dist/agent-setup/cursor.js.map +1 -1
  47. package/node_modules/checkride/dist/agent-setup/harness.d.ts +1 -1
  48. package/node_modules/checkride/dist/agent-setup/harness.d.ts.map +1 -1
  49. package/node_modules/checkride/dist/agent-setup/harness.js +10 -1
  50. package/node_modules/checkride/dist/agent-setup/harness.js.map +1 -1
  51. package/node_modules/checkride/dist/agent-setup/scripts.d.ts +23 -0
  52. package/node_modules/checkride/dist/agent-setup/scripts.d.ts.map +1 -1
  53. package/node_modules/checkride/dist/agent-setup/scripts.js +387 -82
  54. package/node_modules/checkride/dist/agent-setup/scripts.js.map +1 -1
  55. package/node_modules/checkride/dist/baseline/fallow.js +2 -2
  56. package/node_modules/checkride/dist/baseline/fingerprint.d.ts +14 -5
  57. package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -1
  58. package/node_modules/checkride/dist/baseline/fingerprint.js +58 -5
  59. package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -1
  60. package/node_modules/checkride/dist/baseline/history.d.ts +98 -0
  61. package/node_modules/checkride/dist/baseline/history.d.ts.map +1 -0
  62. package/node_modules/checkride/dist/baseline/history.js +232 -0
  63. package/node_modules/checkride/dist/baseline/history.js.map +1 -0
  64. package/node_modules/checkride/dist/baseline/index.d.ts +4 -2
  65. package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -1
  66. package/node_modules/checkride/dist/baseline/index.js +2 -1
  67. package/node_modules/checkride/dist/baseline/index.js.map +1 -1
  68. package/node_modules/checkride/dist/baseline/store.d.ts +30 -7
  69. package/node_modules/checkride/dist/baseline/store.d.ts.map +1 -1
  70. package/node_modules/checkride/dist/baseline/store.js +50 -16
  71. package/node_modules/checkride/dist/baseline/store.js.map +1 -1
  72. package/node_modules/checkride/dist/cli.d.ts +5 -1
  73. package/node_modules/checkride/dist/cli.d.ts.map +1 -1
  74. package/node_modules/checkride/dist/cli.js +98 -25
  75. package/node_modules/checkride/dist/cli.js.map +1 -1
  76. package/node_modules/checkride/dist/config.d.ts +33 -4
  77. package/node_modules/checkride/dist/config.d.ts.map +1 -1
  78. package/node_modules/checkride/dist/config.js +21 -6
  79. package/node_modules/checkride/dist/config.js.map +1 -1
  80. package/node_modules/checkride/dist/digest/digest.js +1 -1
  81. package/node_modules/checkride/dist/doctor.d.ts +2 -0
  82. package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
  83. package/node_modules/checkride/dist/doctor.js +39 -1
  84. package/node_modules/checkride/dist/doctor.js.map +1 -1
  85. package/node_modules/checkride/dist/gate.d.ts +49 -4
  86. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  87. package/node_modules/checkride/dist/gate.js +143 -16
  88. package/node_modules/checkride/dist/gate.js.map +1 -1
  89. package/node_modules/checkride/dist/init.d.ts +8 -1
  90. package/node_modules/checkride/dist/init.d.ts.map +1 -1
  91. package/node_modules/checkride/dist/init.js +68 -7
  92. package/node_modules/checkride/dist/init.js.map +1 -1
  93. package/node_modules/checkride/dist/node-pin.d.ts +1 -1
  94. package/node_modules/checkride/dist/node-pin.js +1 -1
  95. package/node_modules/checkride/dist/orchestrator.d.ts +13 -0
  96. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  97. package/node_modules/checkride/dist/orchestrator.js +118 -13
  98. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  99. package/node_modules/checkride/dist/pm/index.d.ts +1 -0
  100. package/node_modules/checkride/dist/pm/index.d.ts.map +1 -1
  101. package/node_modules/checkride/dist/pm/index.js.map +1 -1
  102. package/node_modules/checkride/dist/pm/launch.d.ts +23 -2
  103. package/node_modules/checkride/dist/pm/launch.d.ts.map +1 -1
  104. package/node_modules/checkride/dist/pm/launch.js +16 -0
  105. package/node_modules/checkride/dist/pm/launch.js.map +1 -1
  106. package/node_modules/checkride/dist/pm/translate.js +1 -1
  107. package/node_modules/checkride/dist/pm/translate.js.map +1 -1
  108. package/node_modules/checkride/dist/qa/mutation.d.ts +4 -4
  109. package/node_modules/checkride/dist/qa/mutation.js +4 -4
  110. package/node_modules/checkride/dist/recover.d.ts +48 -0
  111. package/node_modules/checkride/dist/recover.d.ts.map +1 -0
  112. package/node_modules/checkride/dist/recover.js +220 -0
  113. package/node_modules/checkride/dist/recover.js.map +1 -0
  114. package/node_modules/checkride/dist/smoke.d.ts +1 -1
  115. package/node_modules/checkride/dist/smoke.js +1 -1
  116. package/node_modules/checkride/dist/snippets.d.ts +1 -1
  117. package/node_modules/checkride/package.json +3 -2
  118. package/node_modules/checkride/schema/checkride.config.schema.json +9 -1
  119. package/node_modules/checkride/skills/check/SKILL.md +9 -1
  120. package/node_modules/checkride/templates/shared/styles/Repo/Drift.yml +41 -0
  121. package/node_modules/checkride/templates/shared/styles/Repo/Latin.yml +14 -0
  122. package/node_modules/checkride/templates/shared/styles/Repo/LyHyphen.yml +28 -0
  123. package/node_modules/checkride/templates/shared/styles/Repo/Minted.yml +33 -0
  124. package/node_modules/checkride/templates/shared/styles/Repo/ThereIs.yml +16 -0
  125. package/node_modules/checkride/templates/shared/styles/Repo/Weasel.yml +26 -0
  126. package/node_modules/checkride/templates/shared/vale.ini +34 -0
  127. package/package.json +13 -11
  128. package/skills/abandon/SKILL.md +20 -0
  129. package/skills/build/SKILL.md +69 -64
  130. package/skills/doctor/SKILL.md +11 -11
  131. package/skills/finish/SKILL.md +20 -19
  132. package/skills/harvest/SKILL.md +13 -12
  133. package/skills/park/SKILL.md +10 -10
  134. package/skills/plan/SKILL.md +63 -54
  135. package/skills/recover/SKILL.md +7 -7
  136. package/skills/refine/SKILL.md +18 -16
  137. package/skills/revert/SKILL.md +6 -6
  138. package/skills/spike/SKILL.md +9 -9
  139. package/skills/status/SKILL.md +5 -5
  140. package/skills/step/SKILL.md +17 -12
  141. package/skills/verify/SKILL.md +45 -40
  142. package/templates/build-log.md +7 -7
  143. package/templates/intent.md +49 -39
  144. package/templates/spike-report.md +10 -10
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.
package/dist/lib/latch.js CHANGED
@@ -1,25 +1,25 @@
1
- // The approval latch — the ledger-plane predicate `checkpoint` evaluates before
1
+ // The approval latch: the ledger-plane predicate `checkpoint` evaluates before
2
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
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
5
  // turn intervened since the step was entered, the human granted a one-turn
6
6
  // self-approval from their literal prompt, or the turn ledger is dormant (a
7
7
  // host with no hooks grows no ledger, and the latch stays out of the way rather
8
8
  // than wedging). The ledger is a pair of flat sidecar control files: TURN
9
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.
10
+ // count when a step is entered; TURN > TICK means a human turn intervened.
11
11
  // Five rows, evaluated in order, first hit wins; the refusal message *is* the
12
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
13
+ // model can write that file, and a grant it can forge is no grant; the latch
14
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,
15
+ // TURN/TICK/GRANT and isTTY; it never sniffs the host session. Named exports,
16
16
  // node builtins.
17
17
  import { readFileSync } from 'node:fs';
18
18
  import { grantPath, tickPath, turnPath } from "./sidecar.js";
19
19
  import { resolveBoolean } from "./settings.js";
20
20
  const ALLOW = { allow: true };
21
21
  /**
22
- * The refusal message is the affordance — it tells the model what the pause is,
22
+ * The refusal message is the affordance; it tells the model what the pause is,
23
23
  * how it ends (approval on the next turn lands it; `/plumbbob:build` only starts the
24
24
  * next step), and names the only legitimate grants.
25
25
  */
@@ -31,7 +31,7 @@ boundary; \`/plumbbob:build\` only starts the next step, it never lands this one
31
31
  `;
32
32
  /**
33
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.
34
+ * self-review at plan time; what the human approves is the plan itself.
35
35
  */
36
36
  const NO_TURN_PLAN_MESSAGE = `plumbbob: checkpoint refused — no human turn since \`start\` stamped this plan. This
37
37
  is the plan pause: present the plan, then end the turn; the human's approving
@@ -39,7 +39,7 @@ message is the tick that lets it land on the next run. (An explicit \`/plumbbob:
39
39
  step range in the human's own prompt is the only self-approval.)
40
40
  `;
41
41
  /**
42
- * A settings `auto` is never a grant — a model can write that file, and a grant
42
+ * A settings `auto` is never a grant; a model can write that file, and a grant
43
43
  * it can forge is no grant. When one is set, the refusal names it rather than
44
44
  * silently ignoring it, and points at the only self-approval that works.
45
45
  */
@@ -60,19 +60,19 @@ function ceilingMessage(ceiling) {
60
60
  */
61
61
  export function evaluateLatch(input) {
62
62
  if (input.isTTY)
63
- return ALLOW; // 1 — a human at the keyboard
63
+ return ALLOW; // 1: a human at the keyboard
64
64
  if (input.turn === null || input.tick === null)
65
- return ALLOW; // 2 — dormant ledger / hand-built diff
65
+ return ALLOW; // 2: dormant ledger / hand-built diff
66
66
  if (input.grant?.kind === 'auto')
67
- return ALLOW; // 3 — one-turn grant the human typed
67
+ return ALLOW; // 3: one-turn grant the human typed
68
68
  if (input.grant?.kind === 'range' && input.step !== null) {
69
69
  if (input.step <= input.grant.ceiling)
70
70
  return ALLOW;
71
71
  return { allow: false, reason: 'ceiling', message: ceilingMessage(input.grant.ceiling) };
72
72
  }
73
73
  if (input.turn > input.tick)
74
- return ALLOW; // 4 — a human turn intervened
75
- // 5 — the pause (plan-worded when no step is landing). A settings `auto` never
74
+ return ALLOW; // 4: a human turn intervened
75
+ // 5: the pause (plan-worded when no step is landing). A settings `auto` never
76
76
  // allows; when one is set, the refusal notes it rather than honoring it.
77
77
  const base = input.step === null ? NO_TURN_PLAN_MESSAGE : NO_TURN_MESSAGE;
78
78
  return { allow: false, reason: 'no-turn', message: input.settingsAuto ? base + SETTINGS_AUTO_NOTE : base };
@@ -80,7 +80,7 @@ export function evaluateLatch(input) {
80
80
  /**
81
81
  * Parse a GRANT file's content (`auto` | `range <M>`).
82
82
  *
83
- * Anything else — including a corrupted or hand-mangled file — contributes
83
+ * Anything else (including a corrupted or hand-mangled file) contributes
84
84
  * nothing: no grant, never an error.
85
85
  */
86
86
  export function parseGrant(raw) {
@@ -105,7 +105,7 @@ export function checkLatch(root, step) {
105
105
  tick: readCount(tickPath(root)),
106
106
  grant: readGrant(root),
107
107
  step,
108
- // Read from the settings ladder — but only to decorate the pause, never to allow.
108
+ // Read from the settings ladder, but only to decorate the pause, never to allow.
109
109
  settingsAuto: resolveBoolean(root, 'auto', false),
110
110
  });
111
111
  }
@@ -1,5 +1,5 @@
1
1
  // The orientation model behind `status`: parse a live build session into the
2
- // where-am-I dashboard — title, phase, step list, last checkpoint, counts, and
2
+ // where-am-I dashboard: title, phase, step list, last checkpoint, counts, and
3
3
  // one suggested next move. Pure and best-effort by design: it takes raw file
4
4
  // contents (no fs), and a malformed document degrades to fewer fields rather
5
5
  // than throwing.
@@ -20,7 +20,7 @@ function sectionLines(content, heading) {
20
20
  return lines.slice(start + 1, end);
21
21
  }
22
22
  /**
23
- * The build title — the first `# ` heading in intent.md, or null when absent.
23
+ * The build title: the first `# ` heading in intent.md, or null when absent.
24
24
  */
25
25
  export function parseTitle(intent) {
26
26
  for (const line of intent.split('\n')) {
@@ -32,12 +32,14 @@ export function parseTitle(intent) {
32
32
  return null;
33
33
  }
34
34
  /**
35
- * Parse the steps under `## Steps`: `N. [ |x] Title — **done when:** ...`.
35
+ * Parse the steps under `## Steps`: `N. [ |x] Title, **done when:** ...`
36
+ * (the legacy em-dash separator before the marker parses the same).
36
37
  *
37
- * The title is the text up to the first em dash; `planned` is true when the
38
- * step's block carries a `done when` criterion. Only `## Steps` is machine-read
39
- * as the build plan — narrative roadmap prose lives in its own section and
40
- * never lands here.
38
+ * The title is the text before the `**done when:**` marker on the opener line,
39
+ * trailing separator stripped; an opener without the marker falls back to the
40
+ * text up to the first em dash. `planned` is true when the step's block carries
41
+ * a `done when` criterion. Only `## Steps` is machine-read as the build plan;
42
+ * narrative roadmap prose lives in its own section and never lands here.
41
43
  */
42
44
  export function parseSteps(intent) {
43
45
  const lines = sectionLines(intent, '## Steps');
@@ -46,10 +48,12 @@ export function parseSteps(intent) {
46
48
  const m = /^(\d+)\.\s+\[([ xX])\]\s+(.*)$/.exec(line);
47
49
  if (m) {
48
50
  const rest = m[3] ?? '';
51
+ const marker = /\*\*done when:\*\*/i.exec(rest);
52
+ const head = marker ? rest.slice(0, marker.index) : (rest.split('—')[0] ?? rest);
49
53
  starts.push({
50
54
  n: Number(m[1]),
51
55
  done: (m[2] ?? ' ').toLowerCase() === 'x',
52
- title: (rest.split('—')[0] ?? rest).trim(),
56
+ title: head.replace(/\s*[—,-]+\s*$/, '').trim(),
53
57
  idx,
54
58
  });
55
59
  }
@@ -88,13 +92,28 @@ export function markStepDone(intent, n) {
88
92
  }
89
93
  /**
90
94
  * Count the questions still open: `- Q\d+:` openers under `## Open questions`
91
- * that do not say "resolved".
95
+ * that carry neither a resolution marker nor an unfilled scaffold body.
92
96
  *
93
- * The opener may carry a slug-at-birth gloss — `- Q2 (some-slug): ...` — which
94
- * the count reads through; sub-lines (`*plain:*`/`*lean:*`) never match.
97
+ * The opener may carry a slug-at-birth gloss (`- Q2 (some-slug): ...`) and the
98
+ * anchored form a citable question is born in:
99
+ * `- <a id="q2"></a>**Q2 (some-slug)**: ...`, which is what a `[Q2 (some-slug)](#q2)`
100
+ * reference site elsewhere in the file lands on. The count reads through both, and
101
+ * through any mix of them a hand-edited intent leaves mid-build; sub-lines
102
+ * (`*plain:*`/`*lean:*`) never match.
103
+ *
104
+ * Two rules settle the rest. "Resolved" counts as a marker only as a whole word, so
105
+ * a question whose opener still calls itself "unresolved" keeps counting; a bare
106
+ * substring read that as resolved and dropped a live hole off the dashboard. And an
107
+ * opener whose body has not been filled in yet, still opening on its `<...>`
108
+ * fill-in, is scaffold rather than a question, which is what makes a fresh build
109
+ * report zero rather than one.
95
110
  */
96
111
  export function parseOpenQuestions(intent) {
97
- return sectionLines(intent, '## Open questions').filter((l) => /^- Q\d+(?: \([^)]+\))?:/.test(l.trim()) && !/resolved/i.test(l)).length;
112
+ const opener = /^-\s+(?:<a id="[^"]*"><\/a>\s*)?\*{0,2}Q\d+(?: \([^)]+\))?\*{0,2}:\s*(.*)$/;
113
+ return sectionLines(intent, '## Open questions').filter((l) => {
114
+ const body = opener.exec(l.trim())?.[1];
115
+ return body !== undefined && !/\bresolved\b/i.test(l) && !/^<[^>]*>/.test(body);
116
+ }).length;
98
117
  }
99
118
  /**
100
119
  * Count the open parked items: `- [ ]` lines under `## Park list`.
@@ -122,12 +141,12 @@ export function parseLastCheckpoint(checkpoints) {
122
141
  return last;
123
142
  }
124
143
  /**
125
- * The SHA of the last ledger line of ANY kind — baseline, plan, or step.
144
+ * The SHA of the last ledger line of ANY kind: baseline, plan, or step.
126
145
  *
127
146
  * This is the reconciliation anchor for the out-of-band commit count: a commit
128
147
  * that lands after the plan but before the first step checkpoint is exactly the
129
148
  * window the reconciliation line exists for, so anchoring on step lines alone
130
- * would leave it invisible. `parseLastCheckpoint` stays step-only — it feeds
149
+ * would leave it invisible. `parseLastCheckpoint` stays step-only; it feeds
131
150
  * the "last checkpoint step N" display, where baseline/plan would be noise.
132
151
  */
133
152
  export function lastLedgerSha(checkpoints) {
@@ -162,7 +181,7 @@ function nextMove(spiking, steps, inFlight, parked) {
162
181
  return 'plan the first step — `/plumbbob:step`';
163
182
  }
164
183
  // Batch-default: the steps were planned up front, so finishing them usually
165
- // means "finish up" — but `/plumbbob:step` can still add an increment if reality grew.
184
+ // means "finish up", but `/plumbbob:step` can still add an increment if reality grew.
166
185
  const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/plumbbob:harvest\`; then ` : '';
167
186
  return `${harvest}finish up — \`/plumbbob:finish\` (or \`/plumbbob:step\` to add another increment)`;
168
187
  }
@@ -207,7 +226,7 @@ export function formatOrientation(o) {
207
226
  return head;
208
227
  }
209
228
  // Surface the next step's detail so the human can review it (and `/plumbbob:step`-
210
- // revise) before building. Only what's present — a rough step shows neither.
229
+ // revise) before building. Only what's present: a rough step shows neither.
211
230
  const detail = [];
212
231
  if (o.nextDoneWhen !== null) {
213
232
  detail.push(` done when: ${o.nextDoneWhen}`);
@@ -225,7 +244,7 @@ export function formatOrientation(o) {
225
244
  const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
226
245
  // A neutral reconciliation note, only when there is something to reconcile:
227
246
  // commits landed since the last checkpoint that plumbbob's ledger didn't
228
- // record. Informational — the human commits freely, so this never gates.
247
+ // record. Informational: the human commits freely, so this never gates.
229
248
  const receipts = o.outOfBand > 0
230
249
  ? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
231
250
  : [];
@@ -3,8 +3,8 @@
3
3
  //
4
4
  // The failure it exists to stop is not slowness, it is unbounded recursion. A
5
5
  // repo whose `check` setting is its own full pipeline (`pnpm check`) has a gate
6
- // that runs its test suite; anything in that suite which gates THAT SAME REPO —
7
- // a test driving `checkpoint` against the process cwd — spawns a second full
6
+ // that runs its test suite; anything in that suite which gates THAT SAME REPO
7
+ // (a test driving `checkpoint` against the process cwd) spawns a second full
8
8
  // pipeline, whose suite spawns a third. Each generation forks wider than the
9
9
  // last, and nothing in the loop is aware there is a loop. The only thing that
10
10
  // ended the first observed occurrence was checkride's 600s per-check cap
@@ -13,7 +13,7 @@
13
13
  // It is scoped BY ROOT, and that is the whole design. A flat "a gate is
14
14
  // running" flag is wrong in the one case that matters most: plumbbob's own
15
15
  // suite legitimately gates dozens of fixture repos, and when the suite itself
16
- // is running under this repo's gate it inherits the marker — so a flat flag
16
+ // is running under this repo's gate it inherits the marker, so a flat flag
17
17
  // refuses every one of them and turns a green suite red. Recursion is a repo
18
18
  // re-entering ITS OWN gate; a different root is ordinary nested work and must
19
19
  // pass through untouched.
@@ -1,19 +1,19 @@
1
1
  // The settings ladder: a resolved setting comes from, in priority order,
2
- // 1. a CLI flag — passed in by the verb (undefined when absent)
3
- // 2. settings.local.json — untracked personal overlay, per-worktree
4
- // 3. settings.json — tracked project defaults
5
- // 4. a built-in default — supplied by the caller
2
+ // 1. a CLI flag: passed in by the verb (undefined when absent)
3
+ // 2. settings.local.json: untracked personal overlay, per-worktree
4
+ // 3. settings.json: tracked project defaults
5
+ // 4. a built-in default: supplied by the caller
6
6
  // The first defined rung wins. Both files are optional JSON; a missing or
7
7
  // malformed file contributes nothing rather than throwing, so a broken personal
8
8
  // overlay can never wedge the tool. Functional and procedural, node builtins only.
9
9
  //
10
- // Known keys: `check` (string — the command for the heavy verify gate, a project
11
- // default in settings.json) and `auto` (boolean — a personal preference, so it
10
+ // Known keys: `check` (string: the command for the heavy verify gate, a project
11
+ // default in settings.json) and `auto` (boolean: a personal preference, so it
12
12
  // belongs in settings.local.json). `auto` is NOT a checkpoint grant: the approval
13
- // latch never reads it to allow a land — a model can write this file, and a grant
14
- // it can forge is no grant — only to name it at the pause when set; self-approval
13
+ // latch never reads it to allow a land (a model can write this file, and a grant
14
+ // it can forge is no grant) only to name it at the pause when set; self-approval
15
15
  // comes solely from the human's literal `/build --auto` or a typed step range.
16
- // The per-worktree active-build cursor is NOT here — it lives in the STATE file
16
+ // The per-worktree active-build cursor is NOT here: it lives in the STATE file
17
17
  // (see sidecar.ts), which keeps this overlay a human-authored file rather than
18
18
  // tool-managed state.
19
19
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -34,7 +34,7 @@ export function localSettingsPath(root) {
34
34
  /**
35
35
  * Read one settings file as a plain object.
36
36
  *
37
- * A missing, malformed, or non-object file yields {} — an empty rung that
37
+ * A missing, malformed, or non-object file yields {}: an empty rung that
38
38
  * contributes nothing rather than wedging the tool.
39
39
  */
40
40
  function readSettings(path) {
@@ -61,7 +61,7 @@ function resolveSetting(root, key, flag) {
61
61
  return readSettings(settingsPath(root))[key];
62
62
  }
63
63
  /**
64
- * Resolve a string setting (e.g. `check`).
64
+ * Resolve a string setting (for example `check`).
65
65
  *
66
66
  * A missing rung, or one holding a non-string / blank value, yields the
67
67
  * caller's fallback rather than gating on garbage.
@@ -71,7 +71,7 @@ export function resolveString(root, key, fallback, flag) {
71
71
  return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
72
72
  }
73
73
  /**
74
- * Resolve a boolean setting (e.g. `auto`). A missing or non-boolean rung
74
+ * Resolve a boolean setting (for example `auto`). A missing or non-boolean rung
75
75
  * yields the caller's fallback.
76
76
  */
77
77
  export function resolveBoolean(root, key, fallback, flag) {
@@ -79,10 +79,10 @@ export function resolveBoolean(root, key, fallback, flag) {
79
79
  return typeof value === 'boolean' ? value : fallback;
80
80
  }
81
81
  /**
82
- * Resolve a non-negative integer setting (e.g. `agentTimeout`).
82
+ * Resolve a non-negative integer setting (for example `agentTimeout`).
83
83
  *
84
84
  * A missing rung, or one holding a non-finite / negative / non-integer number,
85
- * yields the caller's fallback rather than a garbage timeout — 0 means "no
85
+ * yields the caller's fallback rather than a garbage timeout: 0 means "no
86
86
  * timeout", so a broken value must never silently become one.
87
87
  */
88
88
  export function resolveNumber(root, key, fallback, flag) {
@@ -90,11 +90,11 @@ export function resolveNumber(root, key, fallback, flag) {
90
90
  return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
91
91
  }
92
92
  /**
93
- * Resolve an object-valued setting (e.g. the `agents` slot-binding defaults)
93
+ * Resolve an object-valued setting (for example the `agents` slot-binding defaults)
94
94
  * across the same ladder.
95
95
  *
96
96
  * The first defined, object-typed rung wins; a missing rung, or one holding a
97
- * non-object / array / scalar, yields {} rather than garbage — mirrors the
97
+ * non-object / array / scalar, yields {} rather than garbage: mirrors the
98
98
  * string/boolean/number resolvers above. No flag rung: the caller (`agent
99
99
  * run`) merges these under the per-build harness bindings, not over a flag.
100
100
  */