plumbbob 0.6.5 → 0.8.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 (113) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/README.md +24 -7
  3. package/dist/cli-core.js +7 -2
  4. package/dist/lib/buildlog.js +5 -2
  5. package/dist/lib/check.js +33 -1
  6. package/dist/lib/git.js +17 -0
  7. package/dist/lib/intent.js +3 -2
  8. package/dist/lib/latch.js +97 -0
  9. package/dist/lib/orient.js +31 -1
  10. package/dist/lib/sidecar.js +116 -0
  11. package/dist/verbs/build.js +5 -1
  12. package/dist/verbs/checkpoint.js +61 -7
  13. package/dist/verbs/doctor.js +33 -2
  14. package/dist/verbs/finish.js +57 -1
  15. package/dist/verbs/revert.js +19 -1
  16. package/dist/verbs/start.js +22 -2
  17. package/dist/verbs/status.js +10 -3
  18. package/dist/verbs/turn.js +106 -0
  19. package/hooks/hooks.json +15 -0
  20. package/hooks/pre-bash-commit.sh +69 -0
  21. package/node_modules/checkride/CHANGELOG.md +111 -0
  22. package/node_modules/checkride/README.md +185 -20
  23. package/node_modules/checkride/dist/adapters.d.ts +9 -4
  24. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  25. package/node_modules/checkride/dist/adapters.js +62 -2
  26. package/node_modules/checkride/dist/adapters.js.map +1 -1
  27. package/node_modules/checkride/dist/agent-setup/hook.d.ts +63 -0
  28. package/node_modules/checkride/dist/agent-setup/hook.d.ts.map +1 -0
  29. package/node_modules/checkride/dist/agent-setup/hook.js +92 -0
  30. package/node_modules/checkride/dist/agent-setup/hook.js.map +1 -0
  31. package/node_modules/checkride/dist/agent-setup/index.d.ts +8 -0
  32. package/node_modules/checkride/dist/agent-setup/index.d.ts.map +1 -0
  33. package/node_modules/checkride/dist/agent-setup/index.js +7 -0
  34. package/node_modules/checkride/dist/agent-setup/index.js.map +1 -0
  35. package/node_modules/checkride/dist/atomic.d.ts +13 -0
  36. package/node_modules/checkride/dist/atomic.d.ts.map +1 -0
  37. package/node_modules/checkride/dist/atomic.js +24 -0
  38. package/node_modules/checkride/dist/atomic.js.map +1 -0
  39. package/node_modules/checkride/dist/baseline/command.d.ts +42 -0
  40. package/node_modules/checkride/dist/baseline/command.d.ts.map +1 -0
  41. package/node_modules/checkride/dist/baseline/command.js +58 -0
  42. package/node_modules/checkride/dist/baseline/command.js.map +1 -0
  43. package/node_modules/checkride/dist/baseline/fingerprint.d.ts +41 -0
  44. package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -0
  45. package/node_modules/checkride/dist/baseline/fingerprint.js +119 -0
  46. package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -0
  47. package/node_modules/checkride/dist/baseline/index.d.ts +13 -0
  48. package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -0
  49. package/node_modules/checkride/dist/baseline/index.js +10 -0
  50. package/node_modules/checkride/dist/baseline/index.js.map +1 -0
  51. package/node_modules/checkride/dist/baseline/store.d.ts +67 -0
  52. package/node_modules/checkride/dist/baseline/store.d.ts.map +1 -0
  53. package/node_modules/checkride/dist/baseline/store.js +110 -0
  54. package/node_modules/checkride/dist/baseline/store.js.map +1 -0
  55. package/node_modules/checkride/dist/cli.d.ts.map +1 -1
  56. package/node_modules/checkride/dist/cli.js +37 -3
  57. package/node_modules/checkride/dist/cli.js.map +1 -1
  58. package/node_modules/checkride/dist/config.d.ts +43 -3
  59. package/node_modules/checkride/dist/config.d.ts.map +1 -1
  60. package/node_modules/checkride/dist/config.js +95 -22
  61. package/node_modules/checkride/dist/config.js.map +1 -1
  62. package/node_modules/checkride/dist/digest/digest.d.ts +52 -0
  63. package/node_modules/checkride/dist/digest/digest.d.ts.map +1 -0
  64. package/node_modules/checkride/dist/digest/digest.js +147 -0
  65. package/node_modules/checkride/dist/digest/digest.js.map +1 -0
  66. package/node_modules/checkride/dist/digest/index.d.ts +8 -0
  67. package/node_modules/checkride/dist/digest/index.d.ts.map +1 -0
  68. package/node_modules/checkride/dist/digest/index.js +7 -0
  69. package/node_modules/checkride/dist/digest/index.js.map +1 -0
  70. package/node_modules/checkride/dist/doctor.d.ts +4 -0
  71. package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
  72. package/node_modules/checkride/dist/doctor.js +33 -10
  73. package/node_modules/checkride/dist/doctor.js.map +1 -1
  74. package/node_modules/checkride/dist/index.d.ts +1 -1
  75. package/node_modules/checkride/dist/index.d.ts.map +1 -1
  76. package/node_modules/checkride/dist/index.js +1 -1
  77. package/node_modules/checkride/dist/index.js.map +1 -1
  78. package/node_modules/checkride/dist/init.d.ts +38 -0
  79. package/node_modules/checkride/dist/init.d.ts.map +1 -1
  80. package/node_modules/checkride/dist/init.js +97 -8
  81. package/node_modules/checkride/dist/init.js.map +1 -1
  82. package/node_modules/checkride/dist/orchestrator.d.ts +58 -1
  83. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  84. package/node_modules/checkride/dist/orchestrator.js +155 -27
  85. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  86. package/node_modules/checkride/dist/pm/detect.d.ts +21 -0
  87. package/node_modules/checkride/dist/pm/detect.d.ts.map +1 -0
  88. package/node_modules/checkride/dist/pm/detect.js +60 -0
  89. package/node_modules/checkride/dist/pm/detect.js.map +1 -0
  90. package/node_modules/checkride/dist/pm/index.d.ts +10 -0
  91. package/node_modules/checkride/dist/pm/index.d.ts.map +1 -0
  92. package/node_modules/checkride/dist/pm/index.js +9 -0
  93. package/node_modules/checkride/dist/pm/index.js.map +1 -0
  94. package/node_modules/checkride/dist/pm/translate.d.ts +28 -0
  95. package/node_modules/checkride/dist/pm/translate.d.ts.map +1 -0
  96. package/node_modules/checkride/dist/pm/translate.js +40 -0
  97. package/node_modules/checkride/dist/pm/translate.js.map +1 -0
  98. package/node_modules/checkride/package.json +20 -17
  99. package/node_modules/checkride/schema/checkride.config.schema.json +97 -0
  100. package/node_modules/checkride/schema/checkride.summary.schema.json +57 -0
  101. package/node_modules/checkride/templates/shared/cspell.json +1 -0
  102. package/node_modules/checkride/templates/shared/prettierrc.json +6 -0
  103. package/package.json +8 -4
  104. package/skills/pb-build/SKILL.md +32 -14
  105. package/skills/pb-finish/SKILL.md +5 -6
  106. package/skills/pb-harvest/SKILL.md +1 -2
  107. package/skills/pb-park/SKILL.md +1 -1
  108. package/skills/pb-plan/SKILL.md +37 -5
  109. package/skills/pb-refine/SKILL.md +0 -1
  110. package/skills/pb-status/SKILL.md +5 -1
  111. package/skills/pb-step/SKILL.md +5 -4
  112. package/skills/pb-verify/SKILL.md +26 -9
  113. package/templates/intent.md +4 -0
@@ -9,8 +9,9 @@
9
9
  // the same way.
10
10
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
12
- import { buildFolder, buildLogPath, checkpointsPath, clearHandoff, hasSession, intentPath, seamPath, stepPath, } from "../lib/sidecar.js";
12
+ import { buildFolder, buildLogPath, bumpStepStat, checkpointsPath, clearHandoff, clearTick, hasSession, intentPath, readStats, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
13
13
  import { runCheck } from "../lib/check.js";
14
+ import { checkLatch } from "../lib/latch.js";
14
15
  import { markStepDone, parseSteps, parseTitle } from "../lib/orient.js";
15
16
  import { parseStepSeam, scopeDrift } from "../lib/intent.js";
16
17
  import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
@@ -28,8 +29,21 @@ export async function checkpoint(cwd, args) {
28
29
  process.stderr.write('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
29
30
  return 1;
30
31
  }
32
+ // The approval latch (D64) precedes the check gate (C4): the tick may not land
33
+ // without a human turn since entry, a grant, or a dormant ledger. The refusal
34
+ // message is the pause affordance — exit 1 and hand the turn back.
35
+ const latch = checkLatch(root, step);
36
+ if (!latch.allow) {
37
+ process.stderr.write(latch.message);
38
+ return 1;
39
+ }
31
40
  const gate = await runCheck(root);
32
41
  if (gate !== 0) {
42
+ if (gate === 1) {
43
+ // The dogfood receipt (research/07 Build 2b): red attempts before green
44
+ // are a per-step count. Harness breakage (2) is not a red check.
45
+ bumpStepStat(root, undefined, step, 'redChecks');
46
+ }
33
47
  process.stderr.write(gate === 2
34
48
  ? 'plumbbob: the check gate itself broke — checkpoint refuses until the harness is fixed.\n'
35
49
  : 'plumbbob: check failed (red) — checkpoint refuses on red. Fix it and re-run.\n');
@@ -47,10 +61,12 @@ export async function checkpoint(cwd, args) {
47
61
  }
48
62
  appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`);
49
63
  flipIntent(root, step);
64
+ stampStepStat(root, undefined, step, 'landedAt', new Date().toISOString());
50
65
  logCheckpoint(root, step, sha);
51
66
  rmSync(seamPath(root), { force: true });
52
67
  rmSync(stepPath(root), { force: true });
53
68
  clearHandoff(root); // the agent-run ledger is step-scoped (D47) — clear it with the markers.
69
+ clearTick(root); // the entry stamp (D64) is spent — the next `build <n>` re-stamps.
54
70
  process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
55
71
  return 0;
56
72
  }
@@ -62,9 +78,20 @@ export async function checkpoint(cwd, args) {
62
78
  // An optional `--body` (stdin heredoc, D34) rides along; the folder is whitelisted
63
79
  // artifact plane, so there is no scope-drift to warn about.
64
80
  function checkpointPlan(root, args) {
81
+ // The latch (D64) covers the plan commit too, keyed on the TICK that `start`
82
+ // stamped: the plan pause is a pause. No step number — a range grant does not
83
+ // speak to a plan.
84
+ const latch = checkLatch(root, null);
85
+ if (!latch.allow) {
86
+ process.stderr.write(latch.message);
87
+ return 1;
88
+ }
65
89
  stagePath(root, buildFolder(root));
66
90
  const sha = commit(root, planSubject(root), bodyArg(args) ?? undefined);
67
91
  appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
92
+ // The plan landing consumes `start`'s entry stamp (D64): a later hand-built diff
93
+ // (no `build <n>`) must find no stale TICK and stay guidance-governed.
94
+ clearTick(root);
68
95
  process.stdout.write(`plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
69
96
  return 0;
70
97
  }
@@ -81,9 +108,10 @@ function planSubject(root) {
81
108
  return title ? `plumbbob: plan — ${title}` : 'plumbbob: plan';
82
109
  }
83
110
  // Step resolution (D3): explicit arg > in-flight STEP file > first undone step in
84
- // intent.md. Returns null when none can be determined.
111
+ // intent.md. Returns null when none can be determined. A `-m` value is a message,
112
+ // never a step — `checkpoint -m "2"` must not read as step 2.
85
113
  function resolveStep(root, args) {
86
- const explicit = args.find((a) => /^\d+$/.test(a));
114
+ const explicit = args.filter((_, i) => args[i - 1] !== '-m').find((a) => /^\d+$/.test(a));
87
115
  if (explicit !== undefined) {
88
116
  return Number(explicit);
89
117
  }
@@ -107,6 +135,7 @@ function warnScopeDrift(root, step) {
107
135
  const seam = seamTokens(root, step);
108
136
  const outside = scopeDrift(stagedPaths(root), seam);
109
137
  if (outside.length > 0) {
138
+ bumpStepStat(root, undefined, step, 'driftWarnings'); // the dogfood receipt (research/07 2b)
110
139
  process.stderr.write(`plumbbob: heads-up — staged paths outside step ${step}'s seam: ${outside.join(', ')}. ` +
111
140
  `The checkpoint captures them; if that's real scope drift, the plan may need a \`/pb-step\` revision.\n`);
112
141
  }
@@ -135,7 +164,10 @@ function flipIntent(root, step) {
135
164
  writeFileSync(intentPath(root), markStepDone(readFileSync(intentPath(root), 'utf8'), step));
136
165
  }
137
166
  catch {
138
- // best-effort bookkeeping; the checkpoint SHA is the source of truth.
167
+ // Best-effort bookkeeping — the checkpoint SHA is the source of truth — but the
168
+ // dashboard reads intent.md, so a swallowed failure here makes orientation lie.
169
+ process.stderr.write(`plumbbob: heads-up — could not flip step ${step} to [x] in intent.md; ` +
170
+ `the checkpoint is recorded, but the dashboard will still show step ${step} as next. Flip it by hand.\n`);
139
171
  }
140
172
  }
141
173
  // Append a dated line to the build-log's `## Log` so the build's history accrues at
@@ -146,7 +178,7 @@ function logCheckpoint(root, step, sha) {
146
178
  try {
147
179
  const path = buildLogPath(root);
148
180
  const date = new Date().toISOString().slice(0, 10);
149
- const line = checkpointLogLine(date, step, sha, titleForStep(root, step));
181
+ const line = checkpointLogLine(date, step, sha, titleForStep(root, step), statsSuffix(root, step));
150
182
  const updated = appendToSection(readFileSync(path, 'utf8'), 'Log', line);
151
183
  if (updated !== null) {
152
184
  writeFileSync(path, updated);
@@ -156,6 +188,27 @@ function logCheckpoint(root, step, sha) {
156
188
  // best-effort ledger; never fail a checkpoint over the build-log.
157
189
  }
158
190
  }
191
+ // The compact receipt riding the Log line (research/07 Build 2b): only what
192
+ // accrued — a clean first-try step gets no suffix at all. Wall-clock needs both
193
+ // stamps (a hand-built step never ran `build <n>`, so it has no startedAt).
194
+ function statsSuffix(root, step) {
195
+ const stats = readStats(root)[String(step)];
196
+ if (stats === undefined)
197
+ return null;
198
+ const parts = [];
199
+ if ((stats.redChecks ?? 0) > 0)
200
+ parts.push(`${stats.redChecks} red`);
201
+ if ((stats.driftWarnings ?? 0) > 0)
202
+ parts.push(`${stats.driftWarnings} drift`);
203
+ if ((stats.reverts ?? 0) > 0)
204
+ parts.push(`${stats.reverts} revert${stats.reverts === 1 ? '' : 's'}`);
205
+ if (stats.startedAt !== undefined && stats.landedAt !== undefined) {
206
+ const ms = Date.parse(stats.landedAt) - Date.parse(stats.startedAt);
207
+ if (Number.isFinite(ms) && ms >= 0)
208
+ parts.push(ms < 60_000 ? '<1m' : `${Math.round(ms / 60_000)}m`);
209
+ }
210
+ return parts.length > 0 ? parts.join(', ') : null;
211
+ }
159
212
  // The CLI-owned, deterministic commit subject: the step's title when intent.md still
160
213
  // carries one, else the bare `plumbbob: step N done` fallback (D34 — the CLI owns the
161
214
  // subject; a `-m` override or `--body` prose is a separate concern).
@@ -179,9 +232,10 @@ function messageArg(args) {
179
232
  // so the skill can compose proportional prose the CLI never could. Returns null
180
233
  // when the flag is absent or stdin is empty — either way the deterministic
181
234
  // fallback body takes over. Reading fd 0 blocks until EOF, which the heredoc
182
- // supplies; a read error (no stdin attached) degrades to the fallback.
235
+ // supplies; a read error (no stdin attached) degrades to the fallback, and an
236
+ // interactive TTY — which would never send EOF — skips the read instead of hanging.
183
237
  function bodyArg(args) {
184
- if (!args.includes('--body')) {
238
+ if (!args.includes('--body') || process.stdin.isTTY === true) {
185
239
  return null;
186
240
  }
187
241
  try {
@@ -28,9 +28,10 @@ import { dirname, isAbsolute, join } from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { runDoctor } from 'checkride';
30
30
  import { listAgents } from "../lib/agents.js";
31
+ import { gateDetectsTools } from "../lib/check.js";
31
32
  import { marketplacePlumbbob } from "../lib/plugins.js";
32
33
  import { findRepoRoot, gitPath, stagePath } from "../lib/git.js";
33
- import { buildDir, excludeControl, listBuilds, sidecarDir, slugify } from "../lib/sidecar.js";
34
+ import { buildDir, excludeControl, listBuilds, readTurn, sidecarDir, slugify } from "../lib/sidecar.js";
34
35
  import { resolveString, settingsPath, setLocalSetting } from "../lib/settings.js";
35
36
  // The plumbbob package's own skills/ dir (the canonical set), off this module's
36
37
  // URL so it resolves the same from src/ (dev) and dist/ (published).
@@ -384,6 +385,15 @@ async function gateReport(cwd) {
384
385
  if (c.required && c.status !== 'ok')
385
386
  failed += 1;
386
387
  }
388
+ // The week-1 bounce, called out where the human can see it coming
389
+ // (research/07 Build 2a): no CODE checks detected means checkpoints are
390
+ // gated only by checkride's always-on repo checks — either a vacuous
391
+ // refusal or, worse, a green that tested nothing. Informational, with the
392
+ // exact fix; `start` surfaces the same probe at plan time.
393
+ if (!gateDetectsTools(report.checks)) {
394
+ lines.push(' ○ gate: no code checks detected — checkpoints would be gated by the always-on repo checks alone;' +
395
+ ' set {"check": "npm test"} in .plumbbob/settings.json, or add tool configs checkride can see');
396
+ }
387
397
  return { lines, failed };
388
398
  }
389
399
  catch (err) {
@@ -402,6 +412,25 @@ function toolRow(c) {
402
412
  const hint = c.status !== 'ok' && c.hint !== null ? `\n → ${c.hint}` : '';
403
413
  return ` ${mark} ${c.slot ?? c.name} ← ${c.adapter}${version}${hint}`;
404
414
  }
415
+ // The approval-latch health probe (D64): is the turn ledger live? `.plumbbob/TURN`
416
+ // holds a count once the UserPromptSubmit hook has ticked at least once, so a present
417
+ // count means the checkpoint latch is armed — its absence means the tick is
418
+ // guidance-only. Dormant is a legitimate state (a host with no hooks behaves exactly
419
+ // as it always has), never a failure — so this section adds nothing to the problem
420
+ // count. The dormant hint names the hook because a missing ledger almost always means
421
+ // it never wired, and that hook rides in either install kind (the marketplace
422
+ // plugin's hooks/hooks.json or an init-style link). Outside a repo there is no
423
+ // worktree ledger to read, so the section is skipped entirely.
424
+ function latchReport(cwd) {
425
+ const root = findRepoRoot(cwd);
426
+ if (root === null)
427
+ return { lines: [], failed: 0 };
428
+ const turn = readTurn(root);
429
+ const line = turn === null
430
+ ? ' ○ latch: dormant — guidance only (no turn ledger yet; it ticks on your first prompt when the UserPromptSubmit hook is wired — re-run after one to confirm)'
431
+ : ` ✓ latch: live (turn ${turn})`;
432
+ return { lines: ['', 'plumbbob doctor — approval latch (D64)', line], failed: 0 };
433
+ }
405
434
  export async function doctor(cwd, args = []) {
406
435
  const checks = pluginChecks();
407
436
  const out = ['plumbbob doctor — plugin install'];
@@ -414,7 +443,9 @@ export async function doctor(cwd, args = []) {
414
443
  out.push(...agents.lines);
415
444
  const gate = await gateReport(cwd);
416
445
  out.push(...gate.lines);
417
- const failed = checks.filter((c) => !c.ok).length + sidecar.failed + agents.failed + gate.failed;
446
+ const latch = latchReport(cwd);
447
+ out.push(...latch.lines);
448
+ const failed = checks.filter((c) => !c.ok).length + sidecar.failed + agents.failed + gate.failed + latch.failed;
418
449
  out.push('');
419
450
  out.push(failed === 0
420
451
  ? 'plumbbob: all checks passed. If a skill still misbehaves, restart Claude Code (or /reload-plugins).'
@@ -8,7 +8,7 @@
8
8
  import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
11
- import { checkpointsPath, hasSession, intentPath, reportPath, resolveBuild, seamPath, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
11
+ import { checkpointsPath, clearTick, hasSession, intentPath, readStats, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
12
12
  import { setLocalSetting } from "../lib/settings.js";
13
13
  import { parseTitle } from "../lib/orient.js";
14
14
  export function finish(cwd, args = []) {
@@ -20,6 +20,7 @@ export function finish(cwd, args = []) {
20
20
  const { build: slug } = resolveBuild(root, args);
21
21
  if (existsSync(reportPath(root, slug))) {
22
22
  appendCheckpointShas(root, slug);
23
+ appendStats(root, slug);
23
24
  }
24
25
  else {
25
26
  process.stderr.write('plumbbob: note — no report.md found; finishing without one ' +
@@ -40,6 +41,12 @@ export function finish(cwd, args = []) {
40
41
  rmSync(seamPath(root, slug), { force: true });
41
42
  rmSync(stepPath(root, slug), { force: true });
42
43
  rmSync(spikePath(root, slug), { force: true });
44
+ // The latch's per-build entry stamp and the one-turn grant go with the session
45
+ // (D64/D65): a grant is one-turn by construction, but the session's last tick is
46
+ // the last time it was rewritten — left behind, a stale `auto` could self-approve
47
+ // the next session's first landing.
48
+ clearTick(root, slug);
49
+ setGrant(root, null);
43
50
  if (slug !== null) {
44
51
  // Drop the activeBuild key — JSON.stringify omits an `undefined` value, so the
45
52
  // cursor is removed while the other local settings (auto, …) survive. Skipped
@@ -81,6 +88,55 @@ function bodyArg(args) {
81
88
  return null;
82
89
  }
83
90
  }
91
+ // Roll the per-step receipts (research/07 Build 2b) into report.md as a `## Stats`
92
+ // table — one row per step plus totals, so "is the loop worth it?" is a table,
93
+ // not a feeling. Silently skipped when nothing accrued (an old build, a build
94
+ // with no red/revert/drift and no `build <n>` stamps has nothing to say).
95
+ function appendStats(root, slug) {
96
+ const stats = readStats(root, slug);
97
+ const steps = Object.keys(stats)
98
+ .map(Number)
99
+ .filter((n) => Number.isFinite(n))
100
+ .sort((a, b) => a - b);
101
+ if (steps.length === 0) {
102
+ return;
103
+ }
104
+ const totals = { redChecks: 0, driftWarnings: 0, reverts: 0, wallMs: 0 };
105
+ const rows = steps.map((n) => {
106
+ const s = stats[String(n)] ?? {};
107
+ totals.redChecks += s.redChecks ?? 0;
108
+ totals.driftWarnings += s.driftWarnings ?? 0;
109
+ totals.reverts += s.reverts ?? 0;
110
+ const wall = wallClockMs(s);
111
+ totals.wallMs += wall ?? 0;
112
+ return `| ${n} | ${s.redChecks ?? 0} | ${s.driftWarnings ?? 0} | ${s.reverts ?? 0} | ${formatWall(wall)} |`;
113
+ });
114
+ appendFileSync(reportPath(root, slug), [
115
+ '',
116
+ '## Stats',
117
+ '',
118
+ '| step | red checks | drift warnings | reverts | wall-clock |',
119
+ '|------|------------|----------------|---------|------------|',
120
+ ...rows,
121
+ `| **total** | ${totals.redChecks} | ${totals.driftWarnings} | ${totals.reverts} | ${formatWall(totals.wallMs > 0 ? totals.wallMs : null)} |`,
122
+ '',
123
+ ].join('\n'));
124
+ }
125
+ function wallClockMs(s) {
126
+ if (s.startedAt === undefined || s.landedAt === undefined) {
127
+ return null;
128
+ }
129
+ const ms = Date.parse(s.landedAt) - Date.parse(s.startedAt);
130
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
131
+ }
132
+ // `—` when unknown (a hand-built step never ran `build <n>`, so it has no
133
+ // startedAt), `<1m` under a minute, whole minutes otherwise.
134
+ function formatWall(ms) {
135
+ if (ms === null) {
136
+ return '—';
137
+ }
138
+ return ms < 60_000 ? '<1m' : `${Math.round(ms / 60_000)}m`;
139
+ }
84
140
  // Append the recorded checkpoints (baseline + each `step n <sha>`) to report.md as a
85
141
  // `## Checkpoints` section, so the report — which now rides the branch into the PR —
86
142
  // lists the SHAs. Best-effort: an unreadable checkpoints file yields an empty list.
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os';
18
18
  import { join } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
21
- import { checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
21
+ import { bumpStepStat, checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
22
22
  import { isArtifactPath, matchesSeam } from "../lib/intent.js";
23
23
  export function revert(cwd, args) {
24
24
  const root = findRepoRoot(cwd);
@@ -53,12 +53,19 @@ export function revert(cwd, args) {
53
53
  // ignored files alone, so they must be removed explicitly afterward).
54
54
  const seam = readSeamTokens(root, slug);
55
55
  const toRemove = untrackedPaths(root).filter((p) => matchesSeam(p, seam) && !isArtifactPath(p));
56
+ // The dogfood receipt (research/07 2b): a revert against an in-flight step is a
57
+ // datapoint — read the marker before it goes, bump after the reset (the sidecar
58
+ // is preserved through it, so the write survives).
59
+ const inFlight = readInFlightStep(root, slug);
56
60
  resetPreserving(root, sha, plumbbobOwnedPaths(root));
57
61
  for (const rel of toRemove) {
58
62
  rmSync(join(root, rel), { force: true, recursive: true });
59
63
  }
60
64
  rmSync(seamPath(root, slug), { force: true });
61
65
  rmSync(stepPath(root, slug), { force: true });
66
+ if (inFlight !== null) {
67
+ bumpStepStat(root, slug, inFlight, 'reverts');
68
+ }
62
69
  process.stdout.write(`plumbbob: reverted to ${sha.slice(0, 9)} — back at the boundary. Park lines and intent edits were preserved.\n`);
63
70
  return 0;
64
71
  }
@@ -139,3 +146,14 @@ function readSeamTokens(root, slug) {
139
146
  return [];
140
147
  }
141
148
  }
149
+ // The in-flight STEP number, or null when none — read for the revert receipt
150
+ // before the marker is cleared.
151
+ function readInFlightStep(root, slug) {
152
+ try {
153
+ const raw = readFileSync(stepPath(root, slug), 'utf8').trim();
154
+ return /^\d+$/.test(raw) ? Number(raw) : null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
@@ -3,14 +3,15 @@
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
+ import { detectGate } from "../lib/check.js";
6
7
  import { findRepoRoot, hasCommit, headSha, isDirty } from "../lib/git.js";
7
- import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, } from "../lib/sidecar.js";
8
+ import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
8
9
  import { settingsPath, setLocalSetting } from "../lib/settings.js";
9
10
  // D24/D32: the gate is checkride unless a `check` setting overrides it, so
10
11
  // settings.json seeds with no `check` key at all — absence IS the default. The
11
12
  // templates' {{CHECK}} line is a human-readable echo of that.
12
13
  const CHECK_ECHO = 'checkride (set a "check" key in .plumbbob/settings.json to override)';
13
- export function start(cwd, args) {
14
+ export async function start(cwd, args) {
14
15
  const positionals = args.filter((a) => !a.startsWith('--'));
15
16
  const allowDirty = args.includes('--allow-dirty');
16
17
  const local = args.includes('--local');
@@ -75,7 +76,26 @@ export function start(cwd, args) {
75
76
  excludeControl(root);
76
77
  intentLocation = `.plumbbob/builds/${slug}/intent.md`;
77
78
  }
79
+ // The plan's entry stamp (D64): `checkpoint --plan` latches on this tick. Skipped
80
+ // when TURN is absent — including the known first-session seam where the hook has
81
+ // never ticked, which leaves that one plan commit guidance-governed. Any grant
82
+ // lying around is a leftover from an earlier session (a legitimate one is minted
83
+ // by a `/pb-build` prompt, which never runs `start`) — clear it so a stale `auto`
84
+ // can't self-approve this session's plan (D65: one-turn lifetime).
85
+ setGrant(root, null);
86
+ stampTick(root, local ? null : slug);
78
87
  process.stdout.write(`plumbbob: started "${title}" — baseline ${sha.slice(0, 9)}. Frame and decide in ${intentLocation}; \`build\` a step once the decisions are made.\n`);
88
+ // The plan-time gate probe (research/07 Build 2a): if checkride sees no code
89
+ // checks here, say so NOW — while the human is still deciding — instead of at
90
+ // the first checkpoint, where the gate either refuses a vacuous run or, worse,
91
+ // greens on the always-on repo checks alone. Guidance only: never the exit
92
+ // code (C1), and a configured `check` or a probe hiccup stays silent.
93
+ const gate = await detectGate(root);
94
+ if (gate.configured === null && !gate.detected) {
95
+ process.stderr.write('plumbbob: heads-up — the check gate sees no code checks in this repo (checkride detected no tools).\n' +
96
+ ' Set one while you are still planning: add {"check": "npm test"} to .plumbbob/settings.json.\n' +
97
+ ' Checkpoints gate on it — with nothing to run, the gate refuses or greens vacuously.\n');
98
+ }
79
99
  return 0;
80
100
  }
81
101
  // Derived slugs carry a `YYYY-MM-DD-` prefix (local time) so `builds/` sorts
@@ -3,10 +3,10 @@
3
3
  // behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
4
4
  import { readFileSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
- import { findRepoRoot } from "../lib/git.js";
6
+ import { commitsSince, findRepoRoot } from "../lib/git.js";
7
7
  import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
8
8
  import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
9
- import { formatOrientation, orient } from "../lib/orient.js";
9
+ import { formatOrientation, lastLedgerSha, orient } from "../lib/orient.js";
10
10
  function readOr(path) {
11
11
  try {
12
12
  return readFileSync(path, 'utf8');
@@ -33,12 +33,19 @@ export function status(cwd, args = []) {
33
33
  }
34
34
  }
35
35
  const inFlightRaw = readOr(stepPath(root, slug)).trim();
36
+ // The out-of-band count (D66) needs git (orient is pure), so status measures it:
37
+ // commits on HEAD since the last ledger line's SHA — baseline, plan, or step, so
38
+ // a commit landing before the first step checkpoint still surfaces. No ledger yet
39
+ // ⇒ nothing to reconcile against ⇒ 0.
40
+ const checkpoints = readOr(checkpointsPath(root, slug));
41
+ const anchor = lastLedgerSha(checkpoints);
36
42
  const orientation = orient({
37
43
  intent: readOr(intentPath(root, slug)),
38
44
  buildLog: readOr(buildLogPath(root, slug)),
39
- checkpoints: readOr(checkpointsPath(root, slug)),
45
+ checkpoints,
40
46
  inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
41
47
  spiking: inSpike(root, slug),
48
+ outOfBand: anchor === null ? 0 : commitsSince(root, anchor),
42
49
  });
43
50
  const lines = [formatOrientation(orientation), ...harnessSection(root, slug)];
44
51
  process.stdout.write(`${lines.join('\n')}\n`);
@@ -0,0 +1,106 @@
1
+ // `plumbbob turn` — the turn-ledger hook (D64). Wired to Claude Code's
2
+ // UserPromptSubmit, it runs once per human prompt: it ticks `.plumbbob/TURN` (the
3
+ // monotonic count of human turns the checkpoint latch reads to know a human turn
4
+ // intervened since a step began) and rewrites `.plumbbob/GRANT` from the literal
5
+ // prompt — minting a one-turn self-approval only when the human typed
6
+ // `/pb-build --auto` or a `N-M` range (D65), clearing it otherwise. Pure machinery,
7
+ // not a user verb: it writes nothing to stdout and ALWAYS exits 0, so a broken tick
8
+ // can never wedge prompting (C3).
9
+ import { readFileSync, writeFileSync } from 'node:fs';
10
+ import { findSessionRoot, setGrant, turnPath } from "../lib/sidecar.js";
11
+ export function turn(cwd, _args) {
12
+ try {
13
+ applyTurn(cwd, readStdin());
14
+ }
15
+ catch {
16
+ // C3: a broken turn must never wedge a prompt — swallow and exit 0.
17
+ }
18
+ return 0;
19
+ }
20
+ // The testable core: apply one tick given the raw hook stdin. Split from the fd-0
21
+ // read so the unit tests can drive it directly — an in-process test cannot feed fd 0
22
+ // (the same constraint checkpoint's `--body` lives under).
23
+ export function applyTurn(cwd, raw) {
24
+ const root = findSessionRoot(cwd);
25
+ if (root === null)
26
+ return 0; // no active session above cwd — silent no-op.
27
+ writeFileSync(turnPath(root), `${readTurn(root) + 1}\n`);
28
+ setGrant(root, grantFromPrompt(extractPrompt(raw)));
29
+ return 0;
30
+ }
31
+ // GRANT is minted only from a literal `pb-build` invocation the human typed:
32
+ // `pb-build` is disable-model-invocation, so this string can only come from a human
33
+ // keystroke, never model-authored pressure (D65). A bounded range beats `--auto`
34
+ // (bounded wins); a bare `/pb-build`, or any non-invocation prompt, mints nothing.
35
+ // The namespaced `/plumbbob:pb-build` form is honored alongside the bare one.
36
+ //
37
+ // Only the invocation's own arguments can mint: the tokens that follow it on its
38
+ // line, up to the first token that isn't argument-shaped (a flag, a step number, a
39
+ // range). An incidental range elsewhere in the prompt — an issue number, a pasted
40
+ // `2020-2024` — is prose, not a grant. Trailing sentence punctuation on an argument
41
+ // (`/pb-build 1-3.`) is still the argument the human typed.
42
+ export function grantFromPrompt(prompt) {
43
+ const invocation = /\/(?:plumbbob:)?pb-build\b/.exec(prompt);
44
+ if (invocation === null)
45
+ return null;
46
+ const line = prompt.slice(invocation.index + invocation[0].length).split('\n', 1)[0] ?? '';
47
+ let auto = false;
48
+ let ceiling;
49
+ for (const token of line.split(/\s+/)) {
50
+ const word = token.replace(/[.,;:!?]+$/, '');
51
+ if (word === '')
52
+ continue;
53
+ if (word === '--auto') {
54
+ auto = true;
55
+ continue;
56
+ }
57
+ const range = /^\d+-(\d+)$/.exec(word);
58
+ if (range !== null) {
59
+ ceiling = range[1];
60
+ continue;
61
+ }
62
+ if (/^\d+$/.test(word) || word.startsWith('-'))
63
+ continue; // a step number or another flag
64
+ break; // free text — the arguments have ended
65
+ }
66
+ if (ceiling !== undefined)
67
+ return `range ${ceiling}`;
68
+ return auto ? 'auto' : null;
69
+ }
70
+ // Extract the `prompt` field from the UserPromptSubmit JSON, or '' when the input is
71
+ // absent, unparseable, or carries no string prompt. Malformed input contributes
72
+ // nothing (D27): the tick still lands (a human turn did occur) and the empty prompt
73
+ // clears any GRANT; the verb never throws over bad stdin.
74
+ function extractPrompt(raw) {
75
+ try {
76
+ const parsed = JSON.parse(raw);
77
+ return typeof parsed.prompt === 'string' ? parsed.prompt : '';
78
+ }
79
+ catch {
80
+ return '';
81
+ }
82
+ }
83
+ // The current TURN as a non-negative integer, or 0 when the ledger is absent or
84
+ // unreadable — so the first tick creates it at 1.
85
+ function readTurn(root) {
86
+ try {
87
+ const n = Number.parseInt(readFileSync(turnPath(root), 'utf8').trim(), 10);
88
+ return Number.isFinite(n) && n >= 0 ? n : 0;
89
+ }
90
+ catch {
91
+ return 0;
92
+ }
93
+ }
94
+ // Read the hook JSON from fd 0. An interactive TTY never sends EOF, so skip the read
95
+ // there rather than hang (mirrors checkpoint's `--body`); a read error degrades to
96
+ // '' — extractPrompt then clears the grant and the tick still lands.
97
+ function readStdin() {
98
+ if (process.stdin.isTTY === true)
99
+ return '';
100
+ try {
101
+ return readFileSync(0, 'utf8');
102
+ }
103
+ catch {
104
+ return '';
105
+ }
106
+ }
package/hooks/hooks.json CHANGED
@@ -1,5 +1,13 @@
1
1
  {
2
2
  "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Bash",
6
+ "hooks": [
7
+ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-bash-commit.sh\"" }
8
+ ]
9
+ }
10
+ ],
3
11
  "PostToolUse": [
4
12
  {
5
13
  "matcher": "Edit|Write|MultiEdit|NotebookEdit",
@@ -7,6 +15,13 @@
7
15
  { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/post-edit.sh\"" }
8
16
  ]
9
17
  }
18
+ ],
19
+ "UserPromptSubmit": [
20
+ {
21
+ "hooks": [
22
+ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/plumbbob\" turn" }
23
+ ]
24
+ }
10
25
  ]
11
26
  }
12
27
  }
@@ -0,0 +1,69 @@
1
+ #!/bin/sh
2
+ # pre-bash-commit.sh — the git-commit ask-hook (D66). PreToolUse on Bash: when a
3
+ # step is in flight and the model reaches for a raw `git commit`, turn that commit
4
+ # into a permission *question*, never a wall. checkpoint owns the landing (the latch
5
+ # lives there); this only nudges the human to route through it. ALWAYS exits 0 and
6
+ # never `deny`s — the human decides, C5 stays intact (D66). `plumbbob checkpoint`'s
7
+ # own internal `git commit` spawn never passes through hooks, so nothing self-trips.
8
+
9
+ # The repo root is the nearest ancestor with an active session — `.plumbbob/STATE`,
10
+ # the sentinel `start` writes and `finish` removes. Present in both the tracked and
11
+ # the `--local` layout (the activeBuild cursor post-edit.sh probes exists only in
12
+ # the tracked one, and this hook must guard `--local` steps too).
13
+ find_root() {
14
+ d=$(pwd -P)
15
+ while [ -n "$d" ]; do
16
+ if [ -f "$d/.plumbbob/STATE" ]; then
17
+ printf '%s' "$d"
18
+ return 0
19
+ fi
20
+ [ "$d" = "/" ] && break
21
+ d=$(dirname "$d")
22
+ done
23
+ return 1
24
+ }
25
+
26
+ root=$(find_root) || exit 0 # no active session here: nothing to guard
27
+
28
+ # The in-flight signal is STEP (cleared when the step checkpoints): inside the
29
+ # active build's folder when the cursor names one, else the flat sidecar STEP of
30
+ # the `--local` layout. jq if present, else the same sed fallback post-edit.sh
31
+ # uses for its field read.
32
+ settings="$root/.plumbbob/settings.local.json"
33
+ slug=$(jq -r '.activeBuild // empty' "$settings" 2>/dev/null)
34
+ [ -z "$slug" ] && slug=$(sed -n 's/.*"activeBuild"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$settings" 2>/dev/null | head -n1)
35
+ if [ -n "$slug" ]; then
36
+ step_file="$root/.plumbbob/builds/$slug/STEP"
37
+ else
38
+ step_file="$root/.plumbbob/STEP"
39
+ fi
40
+ [ -f "$step_file" ] || exit 0 # no step in flight: silent
41
+
42
+ input=$(cat)
43
+ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
44
+ [ -z "$cmd" ] && cmd=$(printf '%s' "$input" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
45
+ [ -z "$cmd" ] && exit 0
46
+
47
+ # A command *mentioning* "git commit" is prose, not a commit — a grep pattern, an
48
+ # echoed sentence, a checkpoint --body heredoc. Best-effort strip before matching:
49
+ # drop heredoc bodies (everything below the first `<<` line; the operator line
50
+ # itself still counts, so `git commit -F- <<MSG` is caught) and quoted string
51
+ # contents. A real commit that follows a heredoc body in the same command slips
52
+ # through unasked — acceptable for an affordance that never gates.
53
+ stripped=$(printf '%s\n' "$cmd" |
54
+ awk '{ print } /<</ { exit }' |
55
+ sed -e "s/'[^']*'//g" -e 's/"[^"]*"//g')
56
+
57
+ # Match a `git … commit` invocation: `git` as a command word (start or after a
58
+ # non-word char), then — without crossing a shell separator (`&& || ; |`) — the
59
+ # `commit` subcommand as a whole word. This clears `git status`/`git diff`/`git log`
60
+ # (no `commit` word) and `git log --grep=commit` (`=commit`, no space before it),
61
+ # while still catching `git -C path commit` and `git commit -m …`.
62
+ printf '%s' "$stripped" |
63
+ grep -Eq '(^|[^[:alnum:]_])git[[:space:]]+([^&|;]*[[:space:]])?commit([[:space:]]|$)' || exit 0
64
+
65
+ # A step is in flight and this is a raw commit: ask (never deny — the human decides).
66
+ # The reason is static, so a plain printf of the literal JSON needs no escaping.
67
+ printf '%s\n' '{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": "plumbbob: a step is in flight — checkpoint owns the landing. Approve only if you asked for this commit."}}'
68
+
69
+ exit 0