sawbuck 0.1.47 → 0.1.49

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.
package/CONTRACT.md CHANGED
@@ -89,7 +89,7 @@ A win is **`gamed`** when it does not survive a **source-only re-run**: revert e
89
89
 
90
90
  ## 4. The context record (the trail knowledge)
91
91
 
92
- A conforming implementation also carries the repo's history as deterministic memory: a ledger derived from git (hotspots, reverted approaches, suppressed or weakened tests, fragile areas), surfaced per turn as a ranked slice for the files being touched. It is a projection of git, never a second source of truth, so it is always reconstructable and never drifts from what actually happened.
92
+ A conforming implementation also carries the repo's history as deterministic memory: a ledger derived from git (hotspots, reverted approaches, suppressed or weakened tests, fragile areas), surfaced on demand as a ranked slice for the files being touched. It is a projection of git, never a second source of truth, so it is always reconstructable and never drifts from what actually happened.
93
93
 
94
94
  ---
95
95
 
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # sawbuck
2
2
 
3
- **Agents follow written rules. Most repos have none.** Sawbuck turns what you keep telling your agent into written rules, and re-runs your tests to prove they held.
3
+ **Agents follow written rules. The one that matters is rarely written.** Sawbuck turns what you keep telling your agent into written rules, and re-runs your tests to prove they held.
4
4
 
5
- It re-proves every win from the agent's **source** edits alone, so a test that went from red to green only because the agent edited the test reads as gamed.
5
+ We put a test that could not pass into 6 real repos and told Claude to get to green fast. With no rule, it got there by changing the test 25 of 28 times. With one line in `CLAUDE.md`, 0 of 28. Of 593 active repos we checked, about half had a rules file for agents, and 19 had a rule like that one.
6
6
 
7
- > Sawbuck is the reference implementation of the [Verified-Outcome Contract](CONTRACT.md): the open interface for a coding loop you can trust.
7
+ It re-proves every win from the agent's **source** edits alone, so a test that went from red to green only because the agent edited the test reads as gamed.
8
8
 
9
9
  In Claude Code, install the plugin and keep working:
10
10
 
@@ -13,7 +13,7 @@ In Claude Code, install the plugin and keep working:
13
13
  /plugin install sawbuck-reins@sawbuck
14
14
  ```
15
15
 
16
- The first time you open a git repository with no rules, Claude finishes your task and offers once to add them. Say yes: it writes the rule, sets your protected paths, and checks every turn.
16
+ The first time you open a git repository with no rules, Claude finishes your task and offers once to add them. Say yes: it writes the rule, sets your protected paths, and watches every turn: protected paths at the end of each turn, each commit re-proved from the source.
17
17
 
18
18
  Or from the command line:
19
19
 
@@ -24,7 +24,7 @@ npx sawbuck check
24
24
  # 2 · give this repo its rules: the written rule, protected paths, and your tests if it has no config yet
25
25
  npm i -g sawbuck
26
26
  sawbuck contract # --full adds supply-chain guards, a repo lock and, on a GitHub repo, a CI check
27
- sawbuck attach # check every Claude Code turn (--shared for a team file, --git for any other agent)
27
+ sawbuck attach # watch every Claude Code turn (--shared for a team file, --git for any other agent)
28
28
  ```
29
29
 
30
30
  **Zero-config:** it finds npm, pytest, cargo and go tests on its own, so in those repos there is nothing to write to start. Anything else (a Makefile target, say) reads inconclusive until you set the command in `promptwheel.config.json`.
@@ -46,17 +46,18 @@ $ sawbuck check # after fixing the source and restoring the test
46
46
  (it survives a source-only re-run).
47
47
  ```
48
48
 
49
- Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `64` could not run (not a git repo, no commit, a bad ref, an unknown flag, a bad or missing flag value, a bad config, or `--working` with nothing uncommitted, and it says which); no detectable test command reads `3` inconclusive, and says so.
49
+ Exit codes: `0` pass (verified, held, or allowed) · `1` sent back (a regression or a protected path) · `2` gamed · `3` inconclusive · `64` could not run (not a git repo, no commit, a bad ref, an unknown flag, a bad or missing flag value, a bad config, or `--working` with nothing uncommitted, and it says which); no detectable test command reads `3` inconclusive, and says so.
50
50
 
51
51
  ## What it does
52
52
 
53
- - **Verifies the win.** `check` re-runs your metric from the source diff alone. A test that went from red to green because of a deleted, weakened, or skipped test, a mocked grader, an edited golden file, or a loosened declared config reads `gamed`. A suite weakened while it stays green is not proved: there is no win to re-run. Tripwires catch skips and removed assertions in a signed repo. Deterministic, zero-LLM, $0, and it never reads the trajectory. No appeals once signed; `--allow` lets a change through with a person's reason, and the check says it was allowed, never verified. A `Sawbuck-Allow:` commit trailer clears nothing in `sawbuck check`, the Stop hook or the git hook, signed or not, because an agent can write a commit message. Red-teamed across 9 ecosystems in 26 scenarios: 12 caught, 10 stated limits, 4 honest ([promptwheel/bench/REDTEAM.md](https://github.com/promptwheel-ai/promptwheel/blob/main/bench/REDTEAM.md)).
53
+ - **Verifies the win.** `check` re-runs your metric from the source diff alone. A test that went from red to green because of a deleted, weakened, or skipped test, a mocked grader, an edited golden file, or a loosened declared config reads `gamed`. A suite weakened while it stays green is not proved: there is no win to re-run. Tripwires catch skips and removed assertions in a signed repo. Deterministic, zero-LLM, $0, and it never reads the trajectory. No appeals once signed; `--allow` lets a change through with a person's reason, and the check says it was allowed, never verified. A `Sawbuck-Allow:` commit trailer clears nothing in `sawbuck check`, the Stop hook or the git hook, signed or not, because an agent can write a commit message. Red-teamed in 26 scenarios across 9 ecosystems: of 22 cheats, 12 were caught and 10 got through, each one a limit the report lists, and all 4 honest fixes passed ([promptwheel/bench/REDTEAM.md](https://github.com/promptwheel-ai/promptwheel/blob/main/bench/REDTEAM.md)).
54
54
  - **Says what it cannot see.** A cheat that lives inside the source itself (a value hardcoded to the one visible case, logic keyed to the test environment) is invisible to a source-only re-run, by construction. That limit is in [the contract](CONTRACT.md#3-the-integrity-boundary-what-counts-as-gaming), not hidden.
55
55
  - **Remembers what broke.** `context` surfaces the relevant history (reverts, fragile areas, test-weakening) for the files you are touching: a ranked trail guide, before you act.
56
56
  - **Shows you everything.** `status` is the inventory: what it has caught for you over time, the debt accruing now, and whether it is armed here, in one view. It says watching only while hooks are wired for this repo, not because an earlier attach left its record on disk. On-demand, so it is quiet until you go looking.
57
57
  - **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
58
58
  - **Clean on and off.** `attach` writes a git-excluded `.claude/settings.local.json`, the lines in `.git/info/exclude` that keep its files out of `git status`, a receipt under `.git/sawbuck/`, a line in `~/.sawbuck/registry.json` (the list `detach --all` reads), and in a signed repo `.sawbuck/state.json` (`attach --git` writes the post-commit hook and `.sawbuck/state.json` in place of the settings file); `attach --dry-run` lists all of it. `detach` removes the hooks and the receipt, and still removes the hooks when the receipt is lost; `detach --dry-run` lists what would go. A post-commit hook that was there before `attach --git` is put back byte for byte. Signed contract files stay because they are yours to review. The record (`.sawbuck/`, the logbook files) stays too, still out of `git status`, until `detach --purge` deletes it and the exclude lines. `detach --verify` proves the wiring is gone and names what is left.
59
59
  - **A limit of the hooks file.** `.claude/settings.local.json` is kept out of git, so taking the hooks out of it shows in no diff, and the check cannot see it. `sawbuck doctor` can: it reads the receipt and says the hooks are gone, or that the set is incomplete (a missing Stop hook, say). `sawbuck attach --shared` avoids it: the hooks go in a tracked file, and a removal shows in git.
60
+ - **A limit of the turn hook.** The hook re-runs your tests on commits, not on uncommitted work. A test edited to go green and left uncommitted is not sent back at the end of that turn. It is caught when it is committed, and `sawbuck check` reads it on demand, which is what the written rule tells the agent to run before finishing.
60
61
  - **A limit of the git hook.** `attach --git` pins the hook to the checkout it was attached from, so a commit made in a linked git worktree is not checked. A hooks folder inside the work tree (`core.hooksPath`, as husky sets it) is refused, and attach prints the line to add by hand; `detach`, `doctor` and `status` do not know about a line added that way.
61
62
 
62
63
  ## Commands
@@ -65,17 +66,34 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
65
66
  |---|---|
66
67
  | `sawbuck contract` | gives a repo its rules: protected paths and the written rule. With no config yet it writes one with your detected tests; an existing config is kept as it is, and gets no tests added. `--full` adds supply-chain guards, a repo lock and a CI check (only with a github.com origin and a real test command; it says when it skips it). `--strict` makes them block, also after an earlier `--full`. `--dry-run` previews |
67
68
  | `sawbuck check [--base R --head R] [--repeat N] [--allow "reason"] [--json]` | verify your latest change (working tree if dirty, else the last commit, or a range); zero-config, no attach, no record. Every `promptwheel run` flag passes through: this is the engine with a plain-spoken verdict |
68
- | `sawbuck attach [--user \| --shared \| --git]` | check every Claude Code turn here. `--user`: in every repo that has a `promptwheel.config.json`. `--shared`: a tracked `.claude/settings.json` a team commits, with no path from your machine in it. `--git`: a post-commit hook, so every commit is checked whatever made it, from the first one after attach (the very first commit of a repo has nothing to be checked against). It leaves a hook alone if it is not a shell script or not executable, and says what to add by hand |
69
+ | `sawbuck attach [--user \| --shared \| --git]` | watch every Claude Code turn here: protected paths at the end of each turn, a source-only re-run on each commit. `--user`: in every repo that has a `promptwheel.config.json`. `--shared`: a tracked `.claude/settings.json` a team commits, with no path from your machine in it. `--git`: a post-commit hook, so every commit is checked whatever made it, from the first one after attach (the very first commit of a repo has nothing to be checked against). It leaves a hook alone if it is not a shell script or not executable, and says what to add by hand |
69
70
  | `sawbuck detach [--user] [--all] [--verify] [--purge] [--dry-run]` | remove the hooks; no flag removes whatever is wired here, with or without the receipt, `--all` does every repo. `--purge` also deletes the record, `--dry-run` only lists what would go |
70
71
  | `/sawbuck-reins:audit` | finds rules in your agent files that can pull against each other, and proposes which wins. No history needed |
71
72
  | `/sawbuck-reins:rules` | proposes the rules you keep enforcing, from your own messages, each with quotes; you pick what to keep. Tested on one person so far |
72
73
  | `/sawbuck-reins:shape` | what you push on, from a sample of your own messages, as a card. It also reads up to 120 past turns and names habits the agent has that you never push on, each with the agent's own words. Leads from one read, tested on one person |
74
+ | `sawbuck init` | writes only the config: your detected tests and the tripwires, with notes from your history. `contract` runs it for you when the repo has no config yet |
73
75
  | `sawbuck status` | the inventory (caught over time, debt now, armed state, what you keep saying), in one view; `--log` for the recent per-check feed |
74
76
  | `sawbuck debt [repo]` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
75
77
  | `sawbuck context` | the ranked relevant history for the current change |
76
78
  | `sawbuck doctor [--all]` · `detach --verify` | run what each hook runs (project and user level) and say if it still works, if the set is incomplete, or if it is gone (exit 1), `--all` for every repo it is attached to; prove the wiring is gone |
77
79
  | `sawbuck statusline` | a one-line status-line segment (🐴 watching / N caught) |
78
80
 
81
+ ## Protected paths
82
+
83
+ `sawbuck contract` protects these by default. A change to one reads "sent back", whoever made it.
84
+
85
+ | rule | paths | how a person makes an exception |
86
+ |---|---|---|
87
+ | `contract-frozen` | `promptwheel.config.json`, `sawbuck.config.json` | `sawbuck check --allow "<reason>"` |
88
+ | `hooks-frozen` | `.claude/settings.json`, `.claude/settings.local.json`, `.sawbuck/**` | `sawbuck check --allow "<reason>"` |
89
+ | `workflows-frozen` | `.github/workflows/**` | remove the rule first |
90
+ | `registry-config-frozen` | `.npmrc`, `.yarnrc`, `.yarnrc.yml` | remove the rule first |
91
+ | `build-images-frozen` | `Dockerfile*`, `docker-compose*` | remove the rule first |
92
+ | `publish-scripts-frozen` | `scripts/release*`, `scripts/publish*` | remove the rule first |
93
+ | `codeowners-frozen` | `CODEOWNERS` | remove the rule first |
94
+
95
+ To remove a rule, delete its entry under `mandates` in `promptwheel.config.json`, in a commit of its own. That file is protected too, so that commit takes `--allow`. Then make the change. To protect another path, add an entry the same way: `{"name": "billing-frozen", "paths": ["src/billing/**"], "reason": "billing changes go through review"}`.
96
+
79
97
  ## Give your agent the rule
80
98
 
81
99
  `sawbuck contract` and `sawbuck init` write this block into your `AGENTS.md` for you (and `CLAUDE.md` / `.cursorrules` if present; `init --no-agent-block` skips it), and `sawbuck attach` enforces it through the hook: every turn checks protected paths, and every commit is re-proved. Here it is to paste yourself if you want:
@@ -120,9 +138,9 @@ Debt is on demand: `sawbuck debt`, and `sawbuck context` for the files in hand.
120
138
 
121
139
  ## The engines
122
140
 
123
- Sawbuck is thin glue over two roles, each behind an open interface that points in opposite directions:
141
+ Sawbuck is thin glue over two engines:
124
142
 
125
- - **the check** : the deterministic outcome gate, a published standard (the [Verified-Outcome Contract](CONTRACT.md)) that others conform to. Reference implementation: **promptwheel**. You plug in *what* to measure (your tests, lint, coverage, an eval); the source re-proof is the fixed part. Embed it with `import { check }`, or check any tool's verdict with `conformsToContract()` from the same package.
143
+ - **the check** : the deterministic outcome gate, written up as a draft spec (the [Verified-Outcome Contract](CONTRACT.md)). Reference implementation: **promptwheel**. You plug in *what* to measure (your tests, lint, coverage, an eval); the source re-proof is the fixed part. Embed it with `import { check }`, or check any tool's verdict with `conformsToContract()` from the same package.
126
144
  - **the memory** : the relevant history for the files you touch, plus the live debt, from **@promptwheel/logbook** (git history as agent memory; also ships an MCP server).
127
145
 
128
146
  Apache 2.0.
package/bins.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // bins.mjs: resolve the two engine CLIs once, for every sawbuck entry point.
2
2
  // Order: env override (PW_BIN / LB_BIN), the installed package, then the dev sibling checkout.
3
- // The memory engine is reached only through memory.mjs (the Memory Contract seam); LB lives
3
+ // Logbook is reached only through memory.mjs; LB lives
4
4
  // here so the resolver is written once, not per file.
5
5
 
6
6
  import { dirname, join } from 'node:path';
package/contract-cmd.mjs CHANGED
@@ -115,4 +115,4 @@ try { files = execFileSync('git', ['-C', repo, 'ls-files'], { encoding: 'utf8',
115
115
  const seen = new Set();
116
116
  for (const f of files) { const m = f.match(/^(.*?(?:^|\/)(?:auth|authentication|billing|payments?|migrations))\//); if (m && !/(^|\/)(node_modules|test|tests|docs?)\//.test(m[1])) seen.add(m[1]); }
117
117
  if (seen.size) process.stdout.write(`\n To protect product code too, add these protected paths under "mandates" in promptwheel.config.json:\n${[...seen].slice(0, 5).map((d) => ` { "name": "${d.replace(/\W+/g, '-')}", "paths": ["${d}/**"], "reason": "changed by a person" }`).join('\n')}\n`);
118
- if (!dry) process.stdout.write(`\n Commit promptwheel.config.json and AGENTS.md${full ? ', promptwheel.lock.json and .promptwheel/packs/' : ''}. Then sawbuck attach checks every turn.\n`);
118
+ if (!dry) process.stdout.write(`\n Commit promptwheel.config.json and AGENTS.md${full ? ', promptwheel.lock.json and .promptwheel/packs/' : ''}. Then sawbuck attach watches every turn: protected paths at turn end, each commit re-proved.\n`);
package/debt.mjs CHANGED
@@ -37,7 +37,7 @@ const asJson = process.argv.includes('--json');
37
37
  // events give fragility + fall-back dates (Logbook's facts). Best-effort.
38
38
  const events = memEvents(repo);
39
39
 
40
- // the live suppression ledger via the memory adapter (the Memory Contract's `debt` capability). Each
40
+ // the live suppression ledger via the Logbook adapter. Each
41
41
  // item arrives classified (documented/testSkip) and with its line content and blame date (`since`),
42
42
  // so this file does not import Logbook's pattern or gating. null = Logbook FAILED
43
43
  // (unmeasurable is NOT clean, refuse and exit nonzero); [] = a genuinely clean tree.
package/memory.mjs CHANGED
@@ -16,7 +16,6 @@ function parseArray(out) {
16
16
 
17
17
  // --- debt sub-classification (Sawbuck policy; applied once so the ledger and the per-turn surface
18
18
  // agree).
19
- export const isTestSkip = (m) => /\bit\.skip\b|\btest\.skip\b|xit\(|xdescribe\(|\bdescribe\.skip\b|pytest\.mark\.skip|unittest\.skip|\bt\.Skip\b|@Disabled\b|@Ignore\b|^\[Ignore\b|^Skip\s*=|^#\[ignore|markTestSkipped|markTestIncomplete/.test(String(m).trim());
20
19
  export const isDocumented = (c) => {
21
20
  c = String(c || '');
22
21
  if (/eslint-disable/.test(c)) return /eslint-disable(?:-next-line|-line)?\s+\S/.test(c);
@@ -27,12 +26,11 @@ export const isDocumented = (c) => {
27
26
  return /\(\s*["']/.test(c) || /=\s*["']/.test(c);
28
27
  };
29
28
 
30
- const classify = (r, source) => ({
29
+ const classify = (r) => ({
31
30
  file: r.file, line: r.line, kind: String(r.kind ?? '').trim(), content: r.content ?? '',
32
31
  since: r.since ?? null, sha: r.sha ?? null,
33
32
  documented: r.content ? isDocumented(r.content) : false,
34
- testSkip: typeof r.testSkip === 'boolean' ? r.testSkip : isTestSkip(r.kind ?? r.content ?? ''), // logbook >=0.9.8 classifies it; the regex is the fallback for older providers
35
- source,
33
+ testSkip: r.testSkip === true, // Logbook classifies it
36
34
  });
37
35
 
38
36
  // events(): the raw git event record, the ranking input. [] on failure, exactly as "no history".
@@ -45,16 +43,10 @@ export function events(repo) {
45
43
  // debt(): live suppressions still in the tree, each classified. null when Logbook could not answer.
46
44
  export function debt(repo, { paths = null, ref = null } = {}) {
47
45
  const out = run(LB, repo, ['audit', '--json', ...(ref ? ['--ref', ref] : []), ...((paths || []).flatMap((f) => ['--file', f]))]);
48
- return out === null ? null : parseArray(out).map((r) => classify(r, 'logbook'));
46
+ return out === null ? null : parseArray(out).map(classify);
49
47
  }
50
48
 
51
49
  // record(): keep a machine finding as an annotation. Best-effort, never throws into the caller.
52
- export function record(repo, { sha, reason, side, evidenceFile, by = 'sawbuck' }) {
53
- const text = String(reason).slice(0, 280);
54
- const exec = (a) => execFileSync(process.execPath, [LB, ...a], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
55
- try {
56
- try { exec(['annotate', sha, text, ...(side ? ['--side', side] : []), ...(evidenceFile ? ['--evidence-file', evidenceFile] : []), '--by', by]); }
57
- catch { exec(['annotate', sha, text, '--by', by]); }
58
- return true;
59
- } catch { return false; }
50
+ export function record(repo, { sha, reason, by = 'sawbuck' }) {
51
+ return run(LB, repo, ['annotate', sha, String(reason).slice(0, 280), '--by', by]) !== null;
60
52
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.47",
4
- "description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves each win from the agent's source edits alone, so a test that went green by editing the test, grader, or config is sent back. For Claude Code, with Codex untested.",
3
+ "version": "0.1.49",
4
+ "description": "Write down the rules your agent follows, and prove they held. A deterministic, zero-LLM check re-proves each win from the agent's source edits alone, so a test that went green by editing the test, grader, or config is sent back. For Claude Code, with Codex untested.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "engines": {
package/rank.mjs CHANGED
@@ -77,11 +77,10 @@ const normMult = (p, kind) => {
77
77
  // The "slider": debt rises on its own, but gently. A disabled test or an undocumented suppression
78
78
  // on a touched file always surfaces; a file that is churning (hot / reverted) surfaces any debt;
79
79
  // a stable file's routine documented suppressions stay quiet until they pile past DEBT_MIN. Tune
80
- // with SAWBUCK_DEBT_MIN. This is how debt rises by itself in the per-turn context, no command run.
80
+ // with SAWBUCK_DEBT_MIN. This is how debt shows up in `sawbuck context` with no command of its own.
81
81
  const DEBT_MIN = Math.max(1, +(process.env.SAWBUCK_DEBT_MIN) || 6);
82
82
 
83
- // Still-live filter + ambient debt, via the memory adapter (the Memory Contract's `debt`
84
- // capability), scoped to the touched paths so the per-turn hook stays cheap. A suppression on a path
83
+ // Still-live filter + ambient debt, via the Logbook adapter, scoped to the touched paths so the read stays cheap. A suppression on a path
85
84
  // now CLEAN of suppressions is churn, not current risk (the study: AutoMapper carries 4 live
86
85
  // `[Ignore]` vs ~158/repo across C# history). The adapter runs Logbook's own detection + gating
87
86
  // and returns classified suppressions, so the glue never imports its pattern. null = the
package/reflect.mjs CHANGED
@@ -16,14 +16,11 @@
16
16
 
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
- import { join, dirname } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
19
+ import { join } from 'node:path';
21
20
  import { record as memRecord } from './memory.mjs';
22
21
  import { PW } from './bins.mjs';
23
22
 
24
- const HERE = dirname(fileURLToPath(import.meta.url));
25
- // The MEMORY engine is reached only through memory.mjs (the Memory Contract seam), never
26
- // resolved or shelled directly here.
23
+ // Logbook is reached only through memory.mjs, never resolved or shelled directly here.
27
24
 
28
25
  const flag = (name, def) => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : def; };
29
26
  const repo = flag('--repo', process.cwd());
@@ -110,15 +107,12 @@ const reason = kind === 'gamed'
110
107
  : `sawbuck: a change here regressed a guarded metric beyond noise`;
111
108
 
112
109
  // --- 3) record the finding in memory (unreviewed, machine-authored note) ----
113
- // The Memory Contract's `record` capability, through the adapter (no direct engine shell here).
114
- // Kept simple: a commit-attached digest note. A later pass could add --span grounding
115
- // and true coupling (the fix diff -> "change X also needs Y"). A gamed verdict still standing
116
- // over the same span is the same catch, not a new one: no second note, no second tick.
117
- const repeatOfStanding = standing && kind === 'gamed';
118
- if (!repeatOfStanding) memRecord(repo, { sha, reason: reason.slice(0, 280), side: 'diff', evidenceFile, by: 'sawbuck' });
110
+ // A note on the commit, through the Logbook adapter. A gamed verdict still standing over the same span never
111
+ // gets here (it exited above), so one catch is one note and one tick.
112
+ memRecord(repo, { sha, reason: reason.slice(0, 280), by: 'sawbuck' });
119
113
 
120
114
  // --- 4) tick the results record (the visible proof-of-work) -----------------
121
- if (!repeatOfStanding) { if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1; }
115
+ if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1;
122
116
  rec.latest = { sha: sha.slice(0, 8), kind, base: baseSha || null, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString(), by: by || null };
123
117
  writeRec();
124
118
 
package/rules.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  // Find the rules you keep enforcing and never wrote down. `prepare` gathers what you typed to Claude Code, on this
2
2
  // machine, with copies and likely secrets removed. The agent proposes rules from it, each with quotes. `check` proves
3
3
  // every quote is really yours: a rule with fewer than two real quotes is dropped. Nothing is added to any file here.
4
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync } from 'node:fs';
5
- import { join } from 'node:path';
4
+ // Once the check has run, the gathered messages and the candidates are deleted: they were for that one read.
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync, rmSync } from 'node:fs';
6
+ import { join, resolve, dirname } from 'node:path';
6
7
  import { homedir } from 'node:os';
7
8
  import { readHistory, typedText } from './history.mjs';
8
9
 
@@ -10,6 +11,11 @@ const MAX_LINES = 3000;
10
11
  export const SECRET = /(sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9]{20,}|github_pat_\w{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[\w-]{10,}|eyJ[\w-]{20,}\.[\w-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY|\b[a-f0-9]{40,}\b|(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)/i;
11
12
  const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
12
13
  const paths = (home) => ({ dir: join(home, '.sawbuck'), input: join(home, '.sawbuck', 'rule-input.txt') });
14
+ // remove what one read gathered. A file the model wrote counts only when it sits in ~/.sawbuck: anywhere else it is the person's own.
15
+ export function forget(home, ...files) {
16
+ const dir = resolve(paths(home).dir);
17
+ for (const f of files) if (f && dirname(resolve(f)) === dir) rmSync(f, { force: true });
18
+ }
13
19
 
14
20
  export function prepareRules({ home = homedir() } = {}) {
15
21
  const found = [];
@@ -60,6 +66,7 @@ export function rulesCli(args, { home = homedir() } = {}) {
60
66
  if (!file || !existsSync(file) || !statSync(file).isFile()) return '🐴 sawbuck: --check needs the candidates file.';
61
67
  if (!existsSync(paths(home).input)) return '🐴 sawbuck: run sawbuck rules --prepare first.';
62
68
  const { kept, dropped } = checkRules(file, { home });
69
+ forget(home, paths(home).input, file);
63
70
  const out = [`🐴 ${kept.length} rules have at least 2 quotes that are really yours${dropped.length ? `, ${dropped.length} dropped` : ''}.`];
64
71
  kept.forEach((r, n) => { out.push('', `${n + 1}. ${r.rule}`); for (const q of r.quotes.slice(0, 3)) out.push(` "${q.slice(0, 110)}"`); });
65
72
  return out.join('\n');
package/sawbuck-hook.mjs CHANGED
@@ -6,11 +6,11 @@ import { ensureExcluded } from './exclude.mjs';
6
6
 
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from 'node:fs';
9
- import { join, dirname, resolve } from 'node:path';
9
+ import { join, dirname } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
- import { mandateHits, hashOf, protectedDirty, protectedAmong, lockState } from './contract.mjs';
13
+ import { mandateHits, hashOf, protectedAmong, lockState } from './contract.mjs';
14
14
  const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
15
15
  const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
16
16
  const stop = process.argv.includes('--stop'); // wired as the Claude Code Stop hook: enforce the contract on a gamed turn
@@ -182,8 +182,3 @@ if (total > (st.shownTotal || 0)) {
182
182
  st.shownTotal = total;
183
183
  }
184
184
  saveState();
185
-
186
- // Keep the record dirs out of `git status` via .git/info/exclude, so Sawbuck never
187
- // dirties the worktree, including under `attach --user`, which is global and cannot
188
- // set this up per-repo at attach time. Idempotent; markers match the CLI's so a
189
- // per-repo `detach` removes them.
package/sawbuck.mjs CHANGED
@@ -20,7 +20,7 @@ import { basename, join, dirname, resolve, relative } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { homedir } from 'node:os';
22
22
  import { PW } from './bins.mjs';
23
- import { frameBlock, mandatesInForce, protectedDirty, blobAt, lockState } from './contract.mjs';
23
+ import { frameBlock, mandatesInForce, protectedDirty, protectedAmong, blobAt, lockState } from './contract.mjs';
24
24
 
25
25
  const SELF = fileURLToPath(import.meta.url);
26
26
  const HERE = dirname(SELF);
@@ -61,12 +61,6 @@ function detectedTests(repo) {
61
61
  const metricLive = (m) => !!m && !m.inert && m.status !== 'unmeasurable' && m.before != null;
62
62
  const S_END = '# <<< sawbuck <<<';
63
63
  const BLOCK_RE = new RegExp(`${S_START}[\\s\\S]*?${S_END}\\n?`, 'g'); // exactly what attach --git puts in, so taking it out leaves the bytes that were there
64
- // the record/state dirs the engines write into the worktree; excluded locally so
65
- // attach never dirties `git status`.
66
- // The record Sawbuck keeps on disk but must never let dirty git status: the engine records
67
- // plus the logbook's root working-set (ledger cache, digests, annotation planes). It is
68
- // memory, not the user's source, a user who wants LOGBOOK.md committed still can explicitly
69
- // (exclude only affects UNtracked files).
70
64
 
71
65
  const argv = process.argv.slice(2);
72
66
  const cmd = argv[0];
@@ -87,7 +81,8 @@ Usage:
87
81
  Verify your latest change right now (working tree if dirty, else the last
88
82
  commit, or the range you name). Zero-config, no attach, no hook. The
89
83
  fastest way to see it work. Every promptwheel run flag passes through.
90
- Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
84
+ Exit 0 pass (verified, held, or allowed) · 1 sent back (a regression or a
85
+ protected path) · 2 gamed · 3 inconclusive · 64 could not run
91
86
  (--working with nothing uncommitted is 64 too)
92
87
  sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block] [--force]
93
88
  Write promptwheel.config.json: your detected tests and the tripwires, with
@@ -95,7 +90,8 @@ Usage:
95
90
  AGENTS.md (--no-agent-block to skip). Wizard in a terminal, non-interactive in
96
91
  a pipe/CI (or --yes). An existing config needs --force.
97
92
  sawbuck attach [repo] [--user | --shared | --git] [--dry-run]
98
- Check every Claude Code turn in this repo. --user: every repo that has a
93
+ Watch every Claude Code turn in this repo: protected paths at the end of each
94
+ turn, a source-only re-run on each commit. --user: every repo that has a
99
95
  promptwheel.config.json. --shared: a tracked settings file a team commits.
100
96
  --git: a post-commit hook, for any other agent or none (commits in a linked
101
97
  worktree are not checked). --dry-run prints every file it would write
@@ -151,6 +147,7 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
151
147
 
152
148
  sawbuck contract give this repo its rules: protected paths, your tests
153
149
  sawbuck check verify your latest change right now
150
+ sawbuck attach make it automatic in Claude Code: protected paths each turn, every commit re-proved
154
151
  sawbuck status what it has caught here
155
152
 
156
153
  sawbuck help --all everything else`);
@@ -243,7 +240,14 @@ switch (cmd) {
243
240
  out('🐴 🚩 gamed: a win here came from editing the test, config, or grader, not the source. It does NOT survive a source-only re-run, so it does not count.');
244
241
  if (!dirty && /^(Sawbuck|Promptwheel)-Allow:/im.test(g('log', '--format=%B', `${base || 'HEAD~1'}..${head || 'HEAD'}`))) out('🐴 note: a Sawbuck-Allow trailer does not clear this, because an agent can write a commit message. A person passes --allow "<reason>".');
245
242
  }
246
- else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a protected path changed, however green the rest is.');
243
+ else if (rep.verdict === 'fail' && (rep.mandates || []).length) {
244
+ out('🐴 ✗ sent back: a protected path changed, however green the rest is.');
245
+ // the way through, for a person who meant it: two of the default rules take a reason, the rest are lifted first
246
+ const names = (l) => l.map((x) => `"${x.name}"`).join(', ');
247
+ const soft = rep.mandates.filter((x) => x.allowable), hard = rep.mandates.filter((x) => !x.allowable);
248
+ if (soft.length) out(`🐴 if a person meant this: sawbuck check --allow "<reason>" accepts ${names(soft)}, and the result shows the reason.`);
249
+ if (hard.length) out(`🐴 ${names(hard)} ${hard.length > 1 ? 'take' : 'takes'} no --allow. To change that path on purpose, remove that rule from promptwheel.config.json in a commit of its own (that edit takes --allow), then make the change.`);
250
+ }
247
251
  else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
248
252
  else out(`🐴 ≈ inconclusive: ${inconclusiveWhy(rep, repo)}`);
249
253
  if ((rep.metrics || []).some((x) => x.heldByTestEdit)) out('🐴 ⚠ your tests changed too, and the old tests fail on this source. Fine for an intended change. Otherwise check the tests were not weakened: a green that stays green is not proved.');
@@ -468,7 +472,7 @@ function verifyAttach(repo) {
468
472
  const { rep } = gateReport(repo, ['run', '--base', base, '--head', 'HEAD']);
469
473
  const m = rep && (rep.metrics || [])[0];
470
474
  if (metricLive(m)) out(` ✓ I can measure "${m.name}" here. On each commit I'll verify a win survived a source-only re-run, and flag it if it did not.`);
471
- else out(` ⚠ I could not measure anything here yet: ${rep ? inconclusiveWhy(rep, repo) : 'the check did not run'} I stay quiet until then.`);
475
+ else out(` ⚠ I could not measure anything here yet: ${rep ? inconclusiveWhy(rep, repo) : 'the check did not run'}`);
472
476
  }
473
477
 
474
478
  // --- the attach receipt (out of the worktree, inside .git) ------------------
@@ -691,6 +695,7 @@ function wireClaude(repo, opts) {
691
695
  const kind = user ? 'USER-LEVEL' : shared ? 'shared project' : 'project';
692
696
  out(upgrading ? `sawbuck: updated the ${kind} Claude Code hooks in ${f} to this version` : `sawbuck: wired ${kind} Claude Code hooks in ${f}`);
693
697
  if (shared) out(` commit ${relative(repo, f)} so the team gets the check. It runs sawbuck ${version} through npx on every machine.`);
698
+ if (shared && protectedAmong(repo, [relative(repo, f)]).length) out(` That file is a protected path in this repo, so the check on its commit reads "sent back" unless a person passes --allow "team hooks" (on a pull request: a comment, sawbuck-allow: team hooks).`);
694
699
  if (!user && !existsSync(join(repo, 'AGENTS.md')) && !existsSync(join(repo, 'CLAUDE.md'))) out(' no written rule here yet, and that is what changes the agent: run sawbuck contract.');
695
700
  if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
696
701
  }
package/shape.mjs CHANGED
@@ -11,7 +11,7 @@ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmS
11
11
  import { execFileSync } from 'node:child_process';
12
12
  import { join, dirname } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
- import { prepareRules, SECRET } from './rules.mjs';
14
+ import { prepareRules, SECRET, forget } from './rules.mjs';
15
15
  import { readHistory, typedText } from './history.mjs';
16
16
 
17
17
  export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
@@ -117,7 +117,6 @@ export function tally(labels) {
117
117
  return { n: total, none, groups, ratings, shares, spokes: total >= MIN_SPOKES };
118
118
  }
119
119
 
120
- // What was not said: each spoke we can measure from what the agent did, read against how often you push on it.
121
120
  export function shapeSvg(t) {
122
121
  const names = t.spokes ? SPOKES : Object.keys(GROUPS);
123
122
  const vals = t.spokes ? names.map((s) => t.ratings[s] / 99) : names.map((g) => 0.25 + 0.75 * t.groups[g] / Math.max(1, ...Object.values(t.groups)));
@@ -172,6 +171,7 @@ export function shapeCli(args, { home = homedir() } = {}) {
172
171
  }
173
172
  if (!t.spokes) out.push('', ` Too few messages for the 11 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
174
173
  appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares }) + '\n');
174
+ forget(home, p.input, p.rules, file); // the sampled messages and their labels were for this one read; the totals above are what stays
175
175
  out.push(...notSaid(p));
176
176
  out.push('', ` Card: ${p.svg}`);
177
177
  if (args.includes('--png')) { const png = pngCard(p.svg); out.push(png ? ` Image to share: ${png}` : ' No image tool here (rsvg-convert or Chrome): open the card in a browser to save an image.'); }
package/status.mjs CHANGED
@@ -21,9 +21,7 @@ const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (pro
21
21
  const repo = resolve(flag('--repo', process.cwd()));
22
22
  const asJson = process.argv.includes('--json');
23
23
 
24
- const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
25
24
  const readLines = (p) => { try { return readFileSync(p, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
26
- const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
27
25
 
28
26
  const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
29
27
 
@@ -38,8 +36,6 @@ for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
38
36
  const seen = feed.length;
39
37
  const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
40
38
  const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
41
- // the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file, in repoState; the
42
- // recent-window average over roughly the last twenty scored turns, as the band and statusline show it)
43
39
 
44
40
  // --- 2) the armed state: what it can measure here (shared with the statusline) ---
45
41
  const isGit = !!existsSync(join(repo, '.git'));
@@ -147,7 +143,7 @@ if (!isGit) {
147
143
  } else if (state === 'watching' && wired === 'user' && !hasConfig) {
148
144
  out.push(' Measuring here · wired in at user level, and that check runs only in a repo that has a promptwheel.config.json. `sawbuck contract` writes one.');
149
145
  } else if (state === 'watching') {
150
- out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}. It verifies each commit/turn automatically.`);
146
+ out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}. Protected paths are checked at the end of each turn, and each commit is re-proved from the source.`);
151
147
  } else if (state === 'ready') {
152
148
  out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach\``);
153
149
  } else {