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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.6.5",
5
- "description": "Attention-first build process — driver skills + a CLI that keep you the decider; guidance, not enforcement.",
4
+ "version": "0.8.0",
5
+ "description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
8
8
  "repository": "https://github.com/robmclarty/plumbbob",
package/README.md CHANGED
@@ -213,13 +213,30 @@ actually learning is the method: decisions before code, one verified step at a
213
213
  time, capture instead of chase. That transfers to whatever tool wins. If
214
214
  something better ships, you walk away with your archives and your habits intact.
215
215
 
216
- **…admit the pause is unenforceable?** It is — on purpose ([D10](docs/decisions.md#d10)/[D13](docs/decisions.md#d13) in
217
- [`docs/decisions.md`](docs/decisions.md)). A hard lock buys ritual, not control:
218
- a determined model routes around it. So PlumbBob enforces deterministically where
219
- it can — `checkpoint` refuses on a red check, `revert` restores only recorded
220
- SHAs — and leaves human what must be human: you, reading the diff at the pause.
221
- When guidance does get blown through, the checkpoint record makes recovery one
222
- command. Cheap recovery, not prevention, is the control that actually matters.
216
+ **…admit the pause is unenforceable?** On the **work** plane, yes — on purpose
217
+ ([D10](docs/decisions.md#d10)/[D13](docs/decisions.md#d13) in
218
+ [`docs/decisions.md`](docs/decisions.md)): a hard lock on every edit buys ritual, not
219
+ control, because a determined model routes around it and a denial mid-thought leaves no
220
+ legal move. But the **record** is a different plane, and there the tick *is* latched
221
+ ([D64–D66](docs/decisions.md#d64)): `checkpoint` refuses to land a step until the harness
222
+ records a human turn since that step began, so the agent cannot self-commit past you —
223
+ the refusal simply *is* the pause. That extends the deterministic edge PlumbBob already
224
+ holds — `checkpoint` refuses a red check, `start` refuses a dirty tree, `revert` restores
225
+ only recorded SHAs — to the one boundary the product is named after, while leaving human
226
+ what must be human: you, reading the diff. **Guidance on the work, a latch on the
227
+ record.** And when a raw commit does slip the ledger, `status` says so and the checkpoint
228
+ record makes recovery one command — cheap recovery, not a cage, is the control that
229
+ matters.
230
+
231
+ > The latch's payoff is measured, not asserted: the skill-eval harness
232
+ > ([`test/evals/`](test/evals/)) sweeps this loop headless — prose-only baseline vs. the
233
+ > shipped latch, N=5 per contract, every assertion a mechanical read of git state — and
234
+ > commits the receipt to [`reports/evals/`](reports/evals/2026-07-11.md). The first sweep
235
+ > (opus, 2026-07-11): the latch closes the route it owns — *no checkpoint over a red
236
+ > check under pressure* went from **2/5 prose-only to 5/5 latched** — while the honest
237
+ > numbers it also surfaced (park capture 0/5, and adversarial pressure consistently
238
+ > flipping the `auto` standing grant in settings — a legal, model-writable side door)
239
+ > are the next iteration's work, named in the report rather than papered over.
223
240
 
224
241
  ## Documentation
225
242
 
package/dist/cli-core.js CHANGED
@@ -18,6 +18,7 @@ import { finish } from "./verbs/finish.js";
18
18
  import { init } from "./verbs/init.js";
19
19
  import { doctor } from "./verbs/doctor.js";
20
20
  import { agent } from "./verbs/agent.js";
21
+ import { turn } from "./verbs/turn.js";
21
22
  const VERBS = [
22
23
  { name: 'start', summary: 'scaffold .plumbbob/; open the session; record the baseline commit' },
23
24
  { name: 'status', summary: 'print the orientation dashboard, or NO ACTIVE SESSION' },
@@ -32,6 +33,7 @@ const VERBS = [
32
33
  { name: 'init', summary: 'link plumbbob into Claude Code as an in-place plugin (~/.claude/skills/plumbbob); --uninstall to undo' },
33
34
  { name: 'doctor', summary: 'diagnose the plugin link + detect a legacy flat sidecar; doctor --migrate moves it into builds/ (staged, not committed)' },
34
35
  { name: 'agent', summary: 'agent list | run <name> [--step N] [--mode before|build|after]: list or spawn a user-authored agent' },
36
+ { name: 'turn', summary: 'turn: UserPromptSubmit hook machinery — tick the turn ledger from stdin (not a user verb)' },
35
37
  ];
36
38
  // PlumbBob (D1/D10/D13): the deciding/executing boundary is a pause, not a lock,
37
39
  // so there is nothing to defend — every verb runs the same whether a human or the
@@ -56,8 +58,9 @@ export function readVersion() {
56
58
  return 'unknown';
57
59
  }
58
60
  }
59
- // Async because the checkride gate (D32) is: `check` and `checkpoint` await it;
60
- // every other verb returns synchronously through the same Promise-typed seam.
61
+ // Async because checkride is: `check` and `checkpoint` await the gate (D32),
62
+ // `start` awaits the plan-time detection probe (research/07 2a); every other
63
+ // verb returns synchronously through the same Promise-typed seam.
61
64
  async function dispatch(verb, cwd, rest) {
62
65
  switch (verb) {
63
66
  case 'start':
@@ -86,6 +89,8 @@ async function dispatch(verb, cwd, rest) {
86
89
  return doctor(cwd, rest);
87
90
  case 'agent':
88
91
  return agent(cwd, rest);
92
+ case 'turn':
93
+ return turn(cwd, rest);
89
94
  default:
90
95
  process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
91
96
  return 1;
@@ -28,7 +28,10 @@ export function appendToSection(content, heading, line) {
28
28
  // The Log line `checkpoint` writes for a landed step: dated, names the step (its
29
29
  // title when intent.md still carries it), and carries the short SHA. One line of the
30
30
  // build's history — `/pb-finish` reads these instead of re-narrating the build.
31
- export function checkpointLogLine(date, step, sha, title) {
31
+ // `stats` is the optional compact receipt (research/07 2b), e.g. `2 red, 34m` —
32
+ // null when nothing accrued, so a clean first-try step reads exactly as before.
33
+ export function checkpointLogLine(date, step, sha, title, stats = null) {
32
34
  const head = `- ${date} — step ${step} checkpointed · ${sha.slice(0, 9)}`;
33
- return title !== null && title.length > 0 ? `${head} — ${title}` : head;
35
+ const titled = title !== null && title.length > 0 ? `${head} — ${title}` : head;
36
+ return stats !== null && stats.length > 0 ? `${titled} (${stats})` : titled;
34
37
  }
package/dist/lib/check.js CHANGED
@@ -12,8 +12,40 @@
12
12
  // green, 1 is red, 2 means the harness itself broke — reported distinctly,
13
13
  // because a misconfigured gate must not read as broken code (both still block).
14
14
  import { spawnSync } from 'node:child_process';
15
- import { runChecks } from 'checkride';
15
+ import { runChecks, runDoctor } from 'checkride';
16
16
  import { resolveString } from "./settings.js";
17
+ export async function detectGate(root) {
18
+ const command = resolveString(root, 'check', '');
19
+ if (command.length > 0)
20
+ return { configured: command, detected: true };
21
+ try {
22
+ const silent = { write: () => true };
23
+ const { report } = await runDoctor({ cwd: root, stdout: silent });
24
+ return { configured: null, detected: gateDetectsTools(report.checks) };
25
+ }
26
+ catch {
27
+ // A broken checkride harness is a different problem with its own reporters
28
+ // (doctor's ✗ row, runCheck's exit 2) — the plan-time probe stays quiet
29
+ // rather than mislabeling it "nothing to check".
30
+ return { configured: null, detected: true };
31
+ }
32
+ }
33
+ // Checkride's always-on repo checks: the adapters its doctor reports even for
34
+ // an EMPTY directory (links, the pnpm-audit security scan, and the
35
+ // publint/attw package-shape probes). They exercise the repo's plumbing, not
36
+ // its code — a gate made only of these green-lights every checkpoint while the
37
+ // human believes their work is being checked. Coupled to checkride's built-in
38
+ // set by construction; the doctor.test.ts probe pins the coupling.
39
+ const ALWAYS_ON_ADAPTERS = ['links', 'pnpm-audit', 'publint', 'attw'];
40
+ // The detection rule, shared by `start`'s probe and doctor's callout: some tool
41
+ // slot beyond the always-on family has an adapter — i.e. checkride can see the
42
+ // CODE (a tsconfig, a test runner, a linter…), not just the repo.
43
+ export function gateDetectsTools(checks) {
44
+ return checks.some((c) => c.category === 'tool' &&
45
+ c.adapter !== null &&
46
+ c.adapter !== undefined &&
47
+ !ALWAYS_ON_ADAPTERS.includes(c.adapter));
48
+ }
17
49
  // Runs the gate in `root` and returns the exit code (0 green, 1 red, 2 harness
18
50
  // error). `commandFlag` is the optional CLI override at the top of the settings
19
51
  // ladder; a resolved command means the spawn path, no setting means checkride.
package/dist/lib/git.js CHANGED
@@ -42,6 +42,23 @@ export function hasCommit(root) {
42
42
  export function isDirty(root) {
43
43
  return runGit(root, ['status', '--porcelain']).length > 0;
44
44
  }
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.
53
+ export function commitsSince(root, sha) {
54
+ try {
55
+ const n = Number.parseInt(runGit(root, ['rev-list', '--count', '--first-parent', `${sha}..HEAD`]), 10);
56
+ return Number.isFinite(n) && n > 0 ? n : 0;
57
+ }
58
+ catch {
59
+ return 0;
60
+ }
61
+ }
45
62
  // --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
46
63
  // only (C5): stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
47
64
  export function stageAll(root) {
@@ -51,12 +51,13 @@ export function parseStepSeam(content, step) {
51
51
  // The seam declaration is the seam line plus any wrapped continuation lines.
52
52
  // Truncate each at an HTML-comment opener so a trailing `<!-- ... -->` note
53
53
  // (which may carry its own backticks) is never read as a seam token; a
54
- // continuation ends at the first line whose pre-comment text has no backtick.
54
+ // 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).
55
56
  const seamStart = seamLines[0] ?? 0;
56
57
  const decl = [];
57
58
  for (let i = seamStart; i < itemLines.length; i++) {
58
59
  const beforeComment = (itemLines[i] ?? '').split('<!--')[0] ?? '';
59
- if (i > seamStart && !beforeComment.includes('`')) {
60
+ if (i > seamStart && (!beforeComment.includes('`') || /^\s*-\s/.test(beforeComment))) {
60
61
  break;
61
62
  }
62
63
  decl.push(beforeComment);
@@ -0,0 +1,97 @@
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 (D65 one-turn grant, D27 standing `auto`), or the
6
+ // ledger is dormant. Six rows, evaluated in order, first hit wins; the refusal
7
+ // message *is* the pause affordance. Reads TURN/TICK/GRANT/isTTY only — never the
8
+ // host (C5). Functional, named exports, node builtins (C1/C2).
9
+ import { readFileSync } from 'node:fs';
10
+ import { grantPath, tickPath, turnPath } from "./sidecar.js";
11
+ import { resolveBoolean } from "./settings.js";
12
+ const ALLOW = { allow: true };
13
+ // The exact refusal (the spec pins this prose): the message is the affordance —
14
+ // it tells the model what the pause is and names the only legitimate grants. The
15
+ // plan commit gets its own wording: there is no step, no diff, and no self-review
16
+ // at plan time — what the human approves is the plan itself.
17
+ const NO_TURN_MESSAGE = `plumbbob: checkpoint refused — no human turn since this step began. This is the
18
+ pause: present the diff and the self-review, then end the turn; the human's next
19
+ message is the tick. (An explicit \`/pb-build --auto\` or a step range in the human's
20
+ own prompt grants self-approval; \`auto: true\` in settings.local.json is the standing
21
+ grant.)
22
+ `;
23
+ const NO_TURN_PLAN_MESSAGE = `plumbbob: checkpoint refused — no human turn since \`start\` stamped this plan. This
24
+ is the plan pause: present the plan, then end the turn; the human's approving
25
+ message is the tick that lets it land on re-fire. (\`auto: true\` in
26
+ settings.local.json is the standing grant.)
27
+ `;
28
+ function ceilingMessage(ceiling) {
29
+ return `plumbbob: checkpoint refused — the range you granted ends at step ${ceiling} — pause here; re-fire to continue.\n`;
30
+ }
31
+ // The six-row matrix, in order, first hit wins. A `range` grant speaks only to
32
+ // numbered steps: on a `--plan` checkpoint (step null) it neither allows nor
33
+ // refuses, and the decision falls through to the turn comparison.
34
+ export function evaluateLatch(input) {
35
+ if (input.isTTY)
36
+ return ALLOW; // 1 — a human at the keyboard
37
+ if (input.turn === null || input.tick === null)
38
+ return ALLOW; // 2 — dormant / hand-built
39
+ if (input.auto)
40
+ return ALLOW; // 3 — standing personal grant (D27)
41
+ if (input.grant?.kind === 'auto')
42
+ return ALLOW; // 4 — one-turn grant
43
+ if (input.grant?.kind === 'range' && input.step !== null) {
44
+ if (input.step <= input.grant.ceiling)
45
+ return ALLOW;
46
+ return { allow: false, reason: 'ceiling', message: ceilingMessage(input.grant.ceiling) };
47
+ }
48
+ if (input.turn > input.tick)
49
+ return ALLOW; // 5 — a human turn intervened
50
+ // 6 — the pause (plan-worded when no step is landing)
51
+ return { allow: false, reason: 'no-turn', message: input.step === null ? NO_TURN_PLAN_MESSAGE : NO_TURN_MESSAGE };
52
+ }
53
+ // Parse a GRANT file's content (`auto` | `range <M>`). Anything else — including
54
+ // a corrupted or hand-mangled file — contributes nothing (D27): no grant, never
55
+ // an error.
56
+ export function parseGrant(raw) {
57
+ const trimmed = raw.trim();
58
+ if (trimmed === 'auto')
59
+ return { kind: 'auto' };
60
+ const range = /^range (\d+)$/.exec(trimmed);
61
+ if (range !== null)
62
+ return { kind: 'range', ceiling: Number(range[1]) };
63
+ return null;
64
+ }
65
+ // Gather the latch's inputs from the worktree and evaluate. `step` is the step
66
+ // number being checkpointed, or null for the plan commit (which latches on the
67
+ // TICK that `start` stamped).
68
+ export function checkLatch(root, step) {
69
+ return evaluateLatch({
70
+ isTTY: process.stdin.isTTY === true,
71
+ turn: readCount(turnPath(root)),
72
+ tick: readCount(tickPath(root)),
73
+ auto: resolveBoolean(root, 'auto', false),
74
+ grant: readGrant(root),
75
+ step,
76
+ });
77
+ }
78
+ function readGrant(root) {
79
+ try {
80
+ return parseGrant(readFileSync(grantPath(root), 'utf8'));
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ // A ledger count, or null when the file is absent or holds garbage — malformed
87
+ // contributes nothing, and an unreadable ledger reads as dormant (row 2) rather
88
+ // than wedging the land.
89
+ function readCount(path) {
90
+ try {
91
+ const n = Number.parseInt(readFileSync(path, 'utf8').trim(), 10);
92
+ return Number.isFinite(n) && n >= 0 ? n : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
@@ -49,7 +49,9 @@ export function parseSteps(intent) {
49
49
  const block = lines.slice(s.idx, blockEnd).join('\n');
50
50
  const dw = /\*\*done when:\*\*\s*(.+)/i.exec(block);
51
51
  const doneWhen = dw ? (dw[1] ?? '').trim() : null;
52
- return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen };
52
+ const md = /^\s*-\s*model:\s*(.+)$/im.exec(block);
53
+ const model = md ? (md[1] ?? '').trim() : null;
54
+ return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model };
53
55
  });
54
56
  }
55
57
  // Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section — mechanical
@@ -92,6 +94,22 @@ export function parseLastCheckpoint(checkpoints) {
92
94
  }
93
95
  return last;
94
96
  }
97
+ // The SHA of the last ledger line of ANY kind — baseline, plan, or step. This is
98
+ // the reconciliation anchor for the out-of-band count (D66): a commit that lands
99
+ // after the plan but before the first step checkpoint is exactly the window the
100
+ // receipt exists for, so anchoring on step lines alone would leave it invisible.
101
+ // `parseLastCheckpoint` stays step-only — it feeds the "last checkpoint step N"
102
+ // display, where baseline/plan would be noise.
103
+ export function lastLedgerSha(checkpoints) {
104
+ let last = null;
105
+ for (const line of checkpoints.split('\n')) {
106
+ const m = /^(?:baseline|plan|step\s+\d+)\s+(\S+)/.exec(line.trim());
107
+ if (m) {
108
+ last = m[1] ?? null;
109
+ }
110
+ }
111
+ return last;
112
+ }
95
113
  // The single primary next move (D15). It suggests; the dashboard prints the full
96
114
  // list + counts so the human can always override. The phase is derived: a spike
97
115
  // in progress and an in-flight step each have one obvious next move; otherwise you
@@ -134,6 +152,8 @@ export function orient(input) {
134
152
  next: nextMove(input.spiking, steps, input.inFlight, parked),
135
153
  nextDoneWhen: nextUndone?.doneWhen ?? null,
136
154
  nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
155
+ nextModel: nextUndone?.model ?? null,
156
+ outOfBand: input.outOfBand,
137
157
  };
138
158
  }
139
159
  export function formatOrientation(o) {
@@ -155,17 +175,27 @@ export function formatOrientation(o) {
155
175
  if (o.nextSeam.length > 0) {
156
176
  detail.push(` seam: ${o.nextSeam.join(', ')}`);
157
177
  }
178
+ if (o.nextModel !== null) {
179
+ detail.push(` model: ${o.nextModel}`);
180
+ }
158
181
  return detail.length > 0 ? [head, ...detail].join('\n') : head;
159
182
  });
160
183
  const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
161
184
  const cp = o.lastCheckpoint;
162
185
  const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
186
+ // A neutral reconciliation note (D66), only when there is something to reconcile:
187
+ // commits landed since the last checkpoint that plumbbob's ledger didn't record.
188
+ // Informational — the human commits freely (repo D3/C5), so this never gates.
189
+ const receipts = o.outOfBand > 0
190
+ ? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
191
+ : [];
163
192
  return [
164
193
  `PlumbBob — ${o.title ?? '(untitled)'} [${o.phase}]`,
165
194
  '',
166
195
  stepsBlock,
167
196
  '',
168
197
  cpLine,
198
+ ...receipts,
169
199
  `parked ${o.parked} · open questions ${o.openQuestions}`,
170
200
  '',
171
201
  `next → ${o.next}`,
@@ -159,6 +159,42 @@ export function appendHandoff(root, slug, entry) {
159
159
  export function clearHandoff(root, slug) {
160
160
  rmSync(handoffPath(root, slug), { force: true });
161
161
  }
162
+ export function statsPath(root, slug) {
163
+ return join(artifactDir(root, slug), 'stats.json');
164
+ }
165
+ // The whole file, or {} when absent or corrupt — malformed contributes nothing.
166
+ export function readStats(root, slug) {
167
+ try {
168
+ const parsed = JSON.parse(readFileSync(statsPath(root, slug), 'utf8'));
169
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
170
+ }
171
+ catch {
172
+ return {};
173
+ }
174
+ }
175
+ // Increment one counter on one step. Best-effort by contract: a failed write is
176
+ // swallowed — the stats are a receipt, never a gate on the verb that accrues them.
177
+ export function bumpStepStat(root, slug, step, key) {
178
+ patchStepStat(root, slug, step, (current) => ({ [key]: (current[key] ?? 0) + 1 }));
179
+ }
180
+ // Stamp one timestamp on one step (startedAt at `build <n>`, landedAt at
181
+ // checkpoint). Same best-effort contract as bumpStepStat.
182
+ export function stampStepStat(root, slug, step, key, value) {
183
+ patchStepStat(root, slug, step, () => ({ [key]: value }));
184
+ }
185
+ function patchStepStat(root, slug, step, patch) {
186
+ try {
187
+ const stats = readStats(root, slug);
188
+ const current = stats[String(step)] ?? {};
189
+ const next = { ...stats, [String(step)]: { ...current, ...patch(current) } };
190
+ const path = statsPath(root, slug);
191
+ mkdirSync(dirname(path), { recursive: true });
192
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
193
+ }
194
+ catch {
195
+ // Best-effort receipt (D27/D4): never wedge build/checkpoint/revert over it.
196
+ }
197
+ }
162
198
  // A session exists iff STATE exists. Deleting STATE (at finish) is what flips the
163
199
  // repo back to "no session" — so it is the single source of truth for "is there
164
200
  // a session". `start` calls beginSession; `finish` removes the file.
@@ -178,6 +214,79 @@ export function markSpike(root, slug) {
178
214
  export function clearSpike(root, slug) {
179
215
  rmSync(spikePath(root, slug), { force: true });
180
216
  }
217
+ // --- The turn ledger (D64/D65): a `.plumbbob/TURN` count the model never writes,
218
+ // and a one-turn `.plumbbob/GRANT` beside it — both flat, per-worktree control
219
+ // (never per-build, never committed). `plumbbob turn` (the UserPromptSubmit hook)
220
+ // ticks TURN once per human prompt and rewrites GRANT from the literal prompt; the
221
+ // checkpoint latch reads them. Kept in the sidecar so the lone GRANT `rmSync` stays
222
+ // where deletions are allowed (C4/centralize-destructive-fs). ---
223
+ export function turnPath(root) {
224
+ return join(root, DIRNAME, 'TURN');
225
+ }
226
+ export function grantPath(root) {
227
+ return join(root, DIRNAME, 'GRANT');
228
+ }
229
+ // Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
230
+ // (`auto` | `range M`), null clears it. Pairing the write with its delete here means
231
+ // a grant can never half-persist, and the delete lives where deletions belong.
232
+ export function setGrant(root, grant) {
233
+ if (grant === null) {
234
+ rmSync(grantPath(root), { force: true });
235
+ }
236
+ else {
237
+ writeFileSync(grantPath(root), `${grant}\n`);
238
+ }
239
+ }
240
+ // TICK is the per-build entry stamp (D64): the TURN value recorded when work was
241
+ // entered (`build <n>` for a step, `start` for the plan), cleared when `checkpoint`
242
+ // lands. The latch compares TURN against it to know whether a human turn intervened
243
+ // across the entry→checkpoint span.
244
+ export function tickPath(root, slug) {
245
+ return join(artifactDir(root, slug), 'TICK');
246
+ }
247
+ // Stamp TICK with the current TURN. Stamped only when TURN holds a readable count:
248
+ // a host with no hooks never grows a ledger, so the latch stays dormant there
249
+ // instead of wedging (row 2 of the matrix).
250
+ export function stampTick(root, slug) {
251
+ const turn = readTurn(root);
252
+ if (turn === null)
253
+ return;
254
+ writeFileSync(tickPath(root, slug), `${turn}\n`);
255
+ }
256
+ // Consume the entry stamp — `checkpoint` clears it when a step (or the plan) lands,
257
+ // the same beat that clears STEP/SEAM/handoff. Absent is a no-op; the rmSync lives
258
+ // here with the sidecar's other deletions.
259
+ export function clearTick(root, slug) {
260
+ rmSync(tickPath(root, slug), { force: true });
261
+ }
262
+ // The TURN count, or null when the ledger is absent or unreadable — absence is the
263
+ // "dormant" signal stampTick keys off and the doctor latch probe reports (D64),
264
+ // never an error.
265
+ export function readTurn(root) {
266
+ try {
267
+ const n = Number.parseInt(readFileSync(turnPath(root), 'utf8').trim(), 10);
268
+ return Number.isFinite(n) && n >= 0 ? n : null;
269
+ }
270
+ catch {
271
+ return null;
272
+ }
273
+ }
274
+ // Walk up from `cwd` to the nearest ancestor with an active session (a
275
+ // `.plumbbob/STATE` file), or null when there is none. The turn hook runs on every
276
+ // human prompt in every repo; this is the cheap, git-free probe that keeps it a
277
+ // silent no-op outside a live plumbbob session — filesystem only, never a host
278
+ // sniff (C5/D13).
279
+ export function findSessionRoot(cwd) {
280
+ let dir = cwd;
281
+ let parent = dirname(dir);
282
+ while (dir !== parent) {
283
+ if (hasSession(dir))
284
+ return dir;
285
+ dir = parent;
286
+ parent = dirname(dir);
287
+ }
288
+ return hasSession(dir) ? dir : null;
289
+ }
181
290
  // Append `patterns` to the repo's info/exclude, each at most once (idempotent —
182
291
  // a re-`start` after finish must not double-add). `gitPath` resolves to the
183
292
  // *common* gitdir's exclude — the only one git reads — so this works from a
@@ -208,9 +317,16 @@ export function excludeControl(root) {
208
317
  addExcludes(root, [
209
318
  `${DIRNAME}/STATE`,
210
319
  `${DIRNAME}/settings.local.json`,
320
+ // The turn ledger and its one-turn grant (D64/D65): per-worktree control the
321
+ // model never writes, and never commits.
322
+ `${DIRNAME}/TURN`,
323
+ `${DIRNAME}/GRANT`,
211
324
  `${DIRNAME}/builds/*/STEP`,
212
325
  `${DIRNAME}/builds/*/SEAM`,
213
326
  `${DIRNAME}/builds/*/SPIKE`,
327
+ // The entry stamp (D64) is in-flight control like STEP/SEAM — checkpoint's
328
+ // stageAll must never sweep it into a step commit.
329
+ `${DIRNAME}/builds/*/TICK`,
214
330
  // The agent-run handoff ledger (D47) is step-scoped in-flight state, not a
215
331
  // tracked artifact — it must never ride a step commit into the PR.
216
332
  `${DIRNAME}/builds/*/handoff.json`,
@@ -3,7 +3,7 @@
3
3
  // BUILD phase from it); it never checkpoints (only `checkpoint` commits).
4
4
  import { readFileSync, writeFileSync } from 'node:fs';
5
5
  import { findRepoRoot } from "../lib/git.js";
6
- import { hasSession, intentPath, resolveBuild, seamPath, stepPath } from "../lib/sidecar.js";
6
+ import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTick, stepPath } from "../lib/sidecar.js";
7
7
  import { parseStepSeam } from "../lib/intent.js";
8
8
  export function build(cwd, args) {
9
9
  const root = findRepoRoot(cwd);
@@ -32,6 +32,10 @@ export function build(cwd, args) {
32
32
  }
33
33
  writeFileSync(seamPath(root, slug), `${parsed.seam.join('\n')}\n`);
34
34
  writeFileSync(stepPath(root, slug), `${step}\n`);
35
+ stampTick(root, slug); // the entry stamp (D64): TICK = TURN; skipped when the ledger is dormant.
36
+ // The wall-clock receipt starts here (research/07 Build 2b): checkpoint stamps
37
+ // landedAt, and the pair becomes the step's duration in the finish report.
38
+ stampStepStat(root, slug, step, 'startedAt', new Date().toISOString());
35
39
  process.stdout.write(`plumbbob: building step ${step}. Seam (for orientation; not a lock):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
36
40
  return 0;
37
41
  }