plumbbob 0.6.6 → 0.8.3

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 (109) 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/latch.js +97 -0
  8. package/dist/lib/orient.js +24 -0
  9. package/dist/lib/settings.js +3 -9
  10. package/dist/lib/sidecar.js +169 -18
  11. package/dist/verbs/build.js +5 -1
  12. package/dist/verbs/checkpoint.js +51 -2
  13. package/dist/verbs/doctor.js +40 -5
  14. package/dist/verbs/finish.js +61 -11
  15. package/dist/verbs/revert.js +19 -1
  16. package/dist/verbs/start.js +38 -7
  17. package/dist/verbs/status.js +10 -3
  18. package/dist/verbs/turn.js +106 -0
  19. package/dist/verbs/use.js +4 -5
  20. package/hooks/hooks.json +15 -0
  21. package/hooks/post-edit.sh +7 -7
  22. package/hooks/pre-bash-commit.sh +68 -0
  23. package/node_modules/checkride/CHANGELOG.md +111 -0
  24. package/node_modules/checkride/README.md +185 -20
  25. package/node_modules/checkride/dist/adapters.d.ts +9 -4
  26. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  27. package/node_modules/checkride/dist/adapters.js +62 -2
  28. package/node_modules/checkride/dist/adapters.js.map +1 -1
  29. package/node_modules/checkride/dist/agent-setup/hook.d.ts +63 -0
  30. package/node_modules/checkride/dist/agent-setup/hook.d.ts.map +1 -0
  31. package/node_modules/checkride/dist/agent-setup/hook.js +92 -0
  32. package/node_modules/checkride/dist/agent-setup/hook.js.map +1 -0
  33. package/node_modules/checkride/dist/agent-setup/index.d.ts +8 -0
  34. package/node_modules/checkride/dist/agent-setup/index.d.ts.map +1 -0
  35. package/node_modules/checkride/dist/agent-setup/index.js +7 -0
  36. package/node_modules/checkride/dist/agent-setup/index.js.map +1 -0
  37. package/node_modules/checkride/dist/atomic.d.ts +13 -0
  38. package/node_modules/checkride/dist/atomic.d.ts.map +1 -0
  39. package/node_modules/checkride/dist/atomic.js +24 -0
  40. package/node_modules/checkride/dist/atomic.js.map +1 -0
  41. package/node_modules/checkride/dist/baseline/command.d.ts +42 -0
  42. package/node_modules/checkride/dist/baseline/command.d.ts.map +1 -0
  43. package/node_modules/checkride/dist/baseline/command.js +58 -0
  44. package/node_modules/checkride/dist/baseline/command.js.map +1 -0
  45. package/node_modules/checkride/dist/baseline/fingerprint.d.ts +41 -0
  46. package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -0
  47. package/node_modules/checkride/dist/baseline/fingerprint.js +119 -0
  48. package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -0
  49. package/node_modules/checkride/dist/baseline/index.d.ts +13 -0
  50. package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -0
  51. package/node_modules/checkride/dist/baseline/index.js +10 -0
  52. package/node_modules/checkride/dist/baseline/index.js.map +1 -0
  53. package/node_modules/checkride/dist/baseline/store.d.ts +67 -0
  54. package/node_modules/checkride/dist/baseline/store.d.ts.map +1 -0
  55. package/node_modules/checkride/dist/baseline/store.js +110 -0
  56. package/node_modules/checkride/dist/baseline/store.js.map +1 -0
  57. package/node_modules/checkride/dist/cli.d.ts.map +1 -1
  58. package/node_modules/checkride/dist/cli.js +37 -3
  59. package/node_modules/checkride/dist/cli.js.map +1 -1
  60. package/node_modules/checkride/dist/config.d.ts +43 -3
  61. package/node_modules/checkride/dist/config.d.ts.map +1 -1
  62. package/node_modules/checkride/dist/config.js +95 -22
  63. package/node_modules/checkride/dist/config.js.map +1 -1
  64. package/node_modules/checkride/dist/digest/digest.d.ts +52 -0
  65. package/node_modules/checkride/dist/digest/digest.d.ts.map +1 -0
  66. package/node_modules/checkride/dist/digest/digest.js +147 -0
  67. package/node_modules/checkride/dist/digest/digest.js.map +1 -0
  68. package/node_modules/checkride/dist/digest/index.d.ts +8 -0
  69. package/node_modules/checkride/dist/digest/index.d.ts.map +1 -0
  70. package/node_modules/checkride/dist/digest/index.js +7 -0
  71. package/node_modules/checkride/dist/digest/index.js.map +1 -0
  72. package/node_modules/checkride/dist/doctor.d.ts +4 -0
  73. package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
  74. package/node_modules/checkride/dist/doctor.js +33 -10
  75. package/node_modules/checkride/dist/doctor.js.map +1 -1
  76. package/node_modules/checkride/dist/index.d.ts +1 -1
  77. package/node_modules/checkride/dist/index.d.ts.map +1 -1
  78. package/node_modules/checkride/dist/index.js +1 -1
  79. package/node_modules/checkride/dist/index.js.map +1 -1
  80. package/node_modules/checkride/dist/init.d.ts +38 -0
  81. package/node_modules/checkride/dist/init.d.ts.map +1 -1
  82. package/node_modules/checkride/dist/init.js +97 -8
  83. package/node_modules/checkride/dist/init.js.map +1 -1
  84. package/node_modules/checkride/dist/orchestrator.d.ts +58 -1
  85. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  86. package/node_modules/checkride/dist/orchestrator.js +155 -27
  87. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  88. package/node_modules/checkride/dist/pm/detect.d.ts +21 -0
  89. package/node_modules/checkride/dist/pm/detect.d.ts.map +1 -0
  90. package/node_modules/checkride/dist/pm/detect.js +60 -0
  91. package/node_modules/checkride/dist/pm/detect.js.map +1 -0
  92. package/node_modules/checkride/dist/pm/index.d.ts +10 -0
  93. package/node_modules/checkride/dist/pm/index.d.ts.map +1 -0
  94. package/node_modules/checkride/dist/pm/index.js +9 -0
  95. package/node_modules/checkride/dist/pm/index.js.map +1 -0
  96. package/node_modules/checkride/dist/pm/translate.d.ts +28 -0
  97. package/node_modules/checkride/dist/pm/translate.d.ts.map +1 -0
  98. package/node_modules/checkride/dist/pm/translate.js +40 -0
  99. package/node_modules/checkride/dist/pm/translate.js.map +1 -0
  100. package/node_modules/checkride/package.json +20 -17
  101. package/node_modules/checkride/schema/checkride.config.schema.json +97 -0
  102. package/node_modules/checkride/schema/checkride.summary.schema.json +57 -0
  103. package/node_modules/checkride/templates/shared/cspell.json +1 -0
  104. package/node_modules/checkride/templates/shared/prettierrc.json +6 -0
  105. package/package.json +8 -4
  106. package/skills/pb-build/SKILL.md +31 -1
  107. package/skills/pb-finish/SKILL.md +2 -1
  108. package/skills/pb-plan/SKILL.md +17 -0
  109. package/skills/pb-verify/SKILL.md +32 -0
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.6.6",
5
- "description": "Attention-first build process — driver skills + a CLI that keep you the decider; guidance, not enforcement.",
4
+ "version": "0.8.3",
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) {
@@ -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
+ }
@@ -94,6 +94,22 @@ export function parseLastCheckpoint(checkpoints) {
94
94
  }
95
95
  return last;
96
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
+ }
97
113
  // The single primary next move (D15). It suggests; the dashboard prints the full
98
114
  // list + counts so the human can always override. The phase is derived: a spike
99
115
  // in progress and an in-flight step each have one obvious next move; otherwise you
@@ -137,6 +153,7 @@ export function orient(input) {
137
153
  nextDoneWhen: nextUndone?.doneWhen ?? null,
138
154
  nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
139
155
  nextModel: nextUndone?.model ?? null,
156
+ outOfBand: input.outOfBand,
140
157
  };
141
158
  }
142
159
  export function formatOrientation(o) {
@@ -166,12 +183,19 @@ export function formatOrientation(o) {
166
183
  const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
167
184
  const cp = o.lastCheckpoint;
168
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
+ : [];
169
192
  return [
170
193
  `PlumbBob — ${o.title ?? '(untitled)'} [${o.phase}]`,
171
194
  '',
172
195
  stepsBlock,
173
196
  '',
174
197
  cpLine,
198
+ ...receipts,
175
199
  `parked ${o.parked} · open questions ${o.openQuestions}`,
176
200
  '',
177
201
  `next → ${o.next}`,
@@ -9,9 +9,9 @@
9
9
  //
10
10
  // Known keys: `check` (string — the heavy gate, tracked in settings.json) and
11
11
  // `auto` (boolean — whether the agent approves in the human's place; a personal
12
- // preference, so it belongs in settings.local.json). `activeBuild` (the
13
- // per-worktree cursor) also lives in settings.local.json but is resolved by
14
- // sidecar.ts, not here.
12
+ // preference, so it belongs in settings.local.json). The per-worktree active-build
13
+ // cursor is NOT here — it lives in the STATE file (see sidecar.ts), so this overlay
14
+ // stays purely human-owned and the tool never rewrites it.
15
15
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join } from 'node:path';
17
17
  const DIRNAME = '.plumbbob';
@@ -70,12 +70,6 @@ export function resolveRecord(root, key) {
70
70
  const value = resolveSetting(root, key, undefined);
71
71
  return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
72
72
  }
73
- // Read one key from the untracked local overlay ONLY — no project or built-in
74
- // fallback. The `activeBuild` cursor lives here and must never resolve from the
75
- // tracked settings.json (it is per-worktree state, not a shared default).
76
- export function localSetting(root, key) {
77
- return readSettings(localSettingsPath(root))[key];
78
- }
79
73
  // Merge one key into settings.local.json, preserving the other keys and creating
80
74
  // the file when absent. Pretty-printed so the overlay stays hand-editable. A
81
75
  // malformed existing file contributes nothing (readSettings yields {}) and is
@@ -2,14 +2,18 @@
2
2
  // read it with a grep and no markdown parsing (D7). Functional/procedural,
3
3
  // node builtins only (C1/C2).
4
4
  //
5
- // STATE is a pure session sentinel: its EXISTENCE means "a session is active",
6
- // and nothing reads its content. The phase the dashboard shows (DESIGN/BUILD/
7
- // SPIKE) is derived, not stored — BUILD ⇔ a STEP is in flight, SPIKE ⇔ the SPIKE
8
- // marker is present, otherwise DESIGN.
5
+ // STATE is the session sentinel AND the active-build cursor (D28): its EXISTENCE
6
+ // means "a session is active", and its CONTENT names the build that session is on —
7
+ // the two reinforce each other (a session is always on some build; the cursor is
8
+ // meaningless without a session). Content is empty under --local / no build.
9
+ // hasSession stays existence-only; activeBuild reads the content. Homing the cursor
10
+ // here — not in settings.local.json — keeps that overlay purely human-owned
11
+ // (check/auto): the tool only ever reads it, never rewrites it. The phase the
12
+ // dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored — BUILD ⇔ a STEP is
13
+ // in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
9
14
  import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
10
15
  import { dirname, join } from 'node:path';
11
16
  import { gitPath } from "./git.js";
12
- import { localSetting } from "./settings.js";
13
17
  const DIRNAME = '.plumbbob';
14
18
  export function sidecarDir(root) {
15
19
  return join(root, DIRNAME);
@@ -50,19 +54,35 @@ export function listBuilds(root) {
50
54
  }
51
55
  }
52
56
  // Resolve which build a verb acts on (D28): an explicit `--build <slug>` flag →
53
- // the `activeBuild` cursor in settings.local.json → the sole build in `builds/`
54
- // → null (the caller then refuses with a hint). One-active-per-worktree holds by
55
- // construction: the cursor is a single scalar key in an untracked file, so it can
56
- // never point at two builds (D28).
57
+ // the cursor in STATE → the sole build in `builds/` → null (the caller then refuses
58
+ // with a hint). One-active-per-worktree holds by construction: the cursor is STATE's
59
+ // single-line content, so it can never point at two builds (D28).
57
60
  export function activeBuild(root, flag) {
58
61
  if (flag !== undefined && flag.length > 0)
59
62
  return flag;
60
- const cursor = localSetting(root, 'activeBuild');
61
- if (typeof cursor === 'string' && cursor.length > 0)
63
+ const cursor = readCursor(root);
64
+ if (cursor !== null)
62
65
  return cursor;
63
66
  const builds = listBuilds(root);
64
67
  return builds.length === 1 ? (builds[0] ?? null) : null;
65
68
  }
69
+ // STATE's content is the active-build cursor. Empty means "no build" (--local, or a
70
+ // session whose cursor is unset) → null, so activeBuild falls through to the
71
+ // sole-build rule. A pre-STATE-cursor session wrote the literal `active` sentinel
72
+ // here (the cursor lived in settings.local.json then); treat that legacy value as
73
+ // unset so an in-flight upgrade degrades gracefully instead of chasing a build named
74
+ // "active". `active` is thus reserved and never a valid cursor — no derived slug
75
+ // collides (they carry a YYYY-MM-DD- prefix).
76
+ function readCursor(root) {
77
+ let content;
78
+ try {
79
+ content = readFileSync(statePath(root), 'utf8').trim();
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ return content.length > 0 && content !== 'active' ? content : null;
85
+ }
66
86
  // The build a verb should act on, plus its argv with the `--build <slug>` pair
67
87
  // stripped (D28). Every verb resolves through this: an explicit `--build <slug>`
68
88
  // wins, else the cursor / sole-build fallback of `activeBuild`. `rest` matters
@@ -159,14 +179,64 @@ export function appendHandoff(root, slug, entry) {
159
179
  export function clearHandoff(root, slug) {
160
180
  rmSync(handoffPath(root, slug), { force: true });
161
181
  }
182
+ export function statsPath(root, slug) {
183
+ return join(artifactDir(root, slug), 'stats.json');
184
+ }
185
+ // The whole file, or {} when absent or corrupt — malformed contributes nothing.
186
+ export function readStats(root, slug) {
187
+ try {
188
+ const parsed = JSON.parse(readFileSync(statsPath(root, slug), 'utf8'));
189
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
190
+ }
191
+ catch {
192
+ return {};
193
+ }
194
+ }
195
+ // Increment one counter on one step. Best-effort by contract: a failed write is
196
+ // swallowed — the stats are a receipt, never a gate on the verb that accrues them.
197
+ export function bumpStepStat(root, slug, step, key) {
198
+ patchStepStat(root, slug, step, (current) => ({ [key]: (current[key] ?? 0) + 1 }));
199
+ }
200
+ // Stamp one timestamp on one step (startedAt at `build <n>`, landedAt at
201
+ // checkpoint). Same best-effort contract as bumpStepStat.
202
+ export function stampStepStat(root, slug, step, key, value) {
203
+ patchStepStat(root, slug, step, () => ({ [key]: value }));
204
+ }
205
+ function patchStepStat(root, slug, step, patch) {
206
+ try {
207
+ const stats = readStats(root, slug);
208
+ const current = stats[String(step)] ?? {};
209
+ const next = { ...stats, [String(step)]: { ...current, ...patch(current) } };
210
+ const path = statsPath(root, slug);
211
+ mkdirSync(dirname(path), { recursive: true });
212
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
213
+ }
214
+ catch {
215
+ // Best-effort receipt (D27/D4): never wedge build/checkpoint/revert over it.
216
+ }
217
+ }
162
218
  // A session exists iff STATE exists. Deleting STATE (at finish) is what flips the
163
219
  // repo back to "no session" — so it is the single source of truth for "is there
164
- // a session". `start` calls beginSession; `finish` removes the file.
220
+ // a session". `start` calls beginSession; `finish` removes the file. Existence is
221
+ // the whole session signal; STATE's content is a separate axis (the cursor).
165
222
  export function hasSession(root) {
166
223
  return existsSync(statePath(root));
167
224
  }
168
- export function beginSession(root) {
169
- writeFileSync(statePath(root), 'active\n');
225
+ // Open the session and point its cursor at `slug` — null under --local / no build,
226
+ // which leaves STATE present but empty so activeBuild falls through to the sole-build
227
+ // rule. `finish` removes the file, closing the session and clearing the cursor in
228
+ // one delete.
229
+ export function beginSession(root, slug = null) {
230
+ writeCursor(root, slug);
231
+ }
232
+ // Re-point the cursor at an existing build (`use`) — a plain content rewrite that
233
+ // leaves the session sentinel (STATE's existence) intact. Callers guard hasSession
234
+ // first, so this never resurrects a finished session.
235
+ export function setActiveBuild(root, slug) {
236
+ writeCursor(root, slug);
237
+ }
238
+ function writeCursor(root, slug) {
239
+ writeFileSync(statePath(root), slug === null ? '' : `${slug}\n`);
170
240
  }
171
241
  // SPIKE marker helpers — existence is the whole signal (content is irrelevant).
172
242
  export function inSpike(root, slug) {
@@ -178,6 +248,79 @@ export function markSpike(root, slug) {
178
248
  export function clearSpike(root, slug) {
179
249
  rmSync(spikePath(root, slug), { force: true });
180
250
  }
251
+ // --- The turn ledger (D64/D65): a `.plumbbob/TURN` count the model never writes,
252
+ // and a one-turn `.plumbbob/GRANT` beside it — both flat, per-worktree control
253
+ // (never per-build, never committed). `plumbbob turn` (the UserPromptSubmit hook)
254
+ // ticks TURN once per human prompt and rewrites GRANT from the literal prompt; the
255
+ // checkpoint latch reads them. Kept in the sidecar so the lone GRANT `rmSync` stays
256
+ // where deletions are allowed (C4/centralize-destructive-fs). ---
257
+ export function turnPath(root) {
258
+ return join(root, DIRNAME, 'TURN');
259
+ }
260
+ export function grantPath(root) {
261
+ return join(root, DIRNAME, 'GRANT');
262
+ }
263
+ // Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
264
+ // (`auto` | `range M`), null clears it. Pairing the write with its delete here means
265
+ // a grant can never half-persist, and the delete lives where deletions belong.
266
+ export function setGrant(root, grant) {
267
+ if (grant === null) {
268
+ rmSync(grantPath(root), { force: true });
269
+ }
270
+ else {
271
+ writeFileSync(grantPath(root), `${grant}\n`);
272
+ }
273
+ }
274
+ // TICK is the per-build entry stamp (D64): the TURN value recorded when work was
275
+ // entered (`build <n>` for a step, `start` for the plan), cleared when `checkpoint`
276
+ // lands. The latch compares TURN against it to know whether a human turn intervened
277
+ // across the entry→checkpoint span.
278
+ export function tickPath(root, slug) {
279
+ return join(artifactDir(root, slug), 'TICK');
280
+ }
281
+ // Stamp TICK with the current TURN. Stamped only when TURN holds a readable count:
282
+ // a host with no hooks never grows a ledger, so the latch stays dormant there
283
+ // instead of wedging (row 2 of the matrix).
284
+ export function stampTick(root, slug) {
285
+ const turn = readTurn(root);
286
+ if (turn === null)
287
+ return;
288
+ writeFileSync(tickPath(root, slug), `${turn}\n`);
289
+ }
290
+ // Consume the entry stamp — `checkpoint` clears it when a step (or the plan) lands,
291
+ // the same beat that clears STEP/SEAM/handoff. Absent is a no-op; the rmSync lives
292
+ // here with the sidecar's other deletions.
293
+ export function clearTick(root, slug) {
294
+ rmSync(tickPath(root, slug), { force: true });
295
+ }
296
+ // The TURN count, or null when the ledger is absent or unreadable — absence is the
297
+ // "dormant" signal stampTick keys off and the doctor latch probe reports (D64),
298
+ // never an error.
299
+ export function readTurn(root) {
300
+ try {
301
+ const n = Number.parseInt(readFileSync(turnPath(root), 'utf8').trim(), 10);
302
+ return Number.isFinite(n) && n >= 0 ? n : null;
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ }
308
+ // Walk up from `cwd` to the nearest ancestor with an active session (a
309
+ // `.plumbbob/STATE` file), or null when there is none. The turn hook runs on every
310
+ // human prompt in every repo; this is the cheap, git-free probe that keeps it a
311
+ // silent no-op outside a live plumbbob session — filesystem only, never a host
312
+ // sniff (C5/D13).
313
+ export function findSessionRoot(cwd) {
314
+ let dir = cwd;
315
+ let parent = dirname(dir);
316
+ while (dir !== parent) {
317
+ if (hasSession(dir))
318
+ return dir;
319
+ dir = parent;
320
+ parent = dirname(dir);
321
+ }
322
+ return hasSession(dir) ? dir : null;
323
+ }
181
324
  // Append `patterns` to the repo's info/exclude, each at most once (idempotent —
182
325
  // a re-`start` after finish must not double-add). `gitPath` resolves to the
183
326
  // *common* gitdir's exclude — the only one git reads — so this works from a
@@ -200,17 +343,25 @@ function addExcludes(root, patterns) {
200
343
  appendFileSync(exclude, `${prefix}${missing.join('\n')}\n`);
201
344
  }
202
345
  // The narrowed control plane (D17): with `builds/<slug>/` now tracked, only the
203
- // per-worktree control files stay git-excluded — the local settings overlay (its
204
- // `activeBuild` cursor), the session sentinel, and the in-flight step markers
205
- // inside every build. Everything else under `.plumbbob/` (settings.json, and each
206
- // build's intent/build-log/checkpoints/report) rides the branch into the PR.
346
+ // per-worktree control files stay git-excluded — the session sentinel STATE (whose
347
+ // content is the active-build cursor, D28), the personal settings overlay, and the
348
+ // in-flight step markers inside every build. Everything else under `.plumbbob/`
349
+ // (settings.json, and each build's intent/build-log/checkpoints/report) rides the
350
+ // branch into the PR.
207
351
  export function excludeControl(root) {
208
352
  addExcludes(root, [
209
353
  `${DIRNAME}/STATE`,
210
354
  `${DIRNAME}/settings.local.json`,
355
+ // The turn ledger and its one-turn grant (D64/D65): per-worktree control the
356
+ // model never writes, and never commits.
357
+ `${DIRNAME}/TURN`,
358
+ `${DIRNAME}/GRANT`,
211
359
  `${DIRNAME}/builds/*/STEP`,
212
360
  `${DIRNAME}/builds/*/SEAM`,
213
361
  `${DIRNAME}/builds/*/SPIKE`,
362
+ // The entry stamp (D64) is in-flight control like STEP/SEAM — checkpoint's
363
+ // stageAll must never sweep it into a step commit.
364
+ `${DIRNAME}/builds/*/TICK`,
214
365
  // The agent-run handoff ledger (D47) is step-scoped in-flight state, not a
215
366
  // tracked artifact — it must never ride a step commit into the PR.
216
367
  `${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
  }