sawbuck 0.1.35 → 0.1.37

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/README.md CHANGED
@@ -2,10 +2,21 @@
2
2
 
3
3
  **Sign your agent to a contract so it does what you want.**
4
4
 
5
- You write the contract (what a real win looks like); Sawbuck enforces it, re-proving every win from the agent's **source** edits alone, so a result that only passed because the agent edited the test, mocked the grader, or loosened a rule reads `gamed` and goes back. Deterministic and zero-LLM: no model in the verdict, nothing read from the agent's trajectory. Attach it and it carries your repo's history along: what broke in these files before, surfaced before you touch them.
5
+ You write the contract (what a real win looks like); Sawbuck enforces it, re-proving 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, mocked the grader, or loosened a declared config reads `gamed` and goes back. Deterministic and zero-LLM: no model in the verdict, nothing read from the agent's trajectory. Attach it and it carries your repo's history along: what broke in these files before, surfaced before you touch them.
6
6
 
7
7
  > Sawbuck is the reference implementation of the [Verified-Outcome Contract](CONTRACT.md): the open interface for a coding loop you can trust.
8
8
 
9
+ In Claude Code, install the plugin and keep working:
10
+
11
+ ```
12
+ /plugin marketplace add sawbuck-ai/reins
13
+ /plugin install sawbuck-reins@sawbuck
14
+ ```
15
+
16
+ The first time you open a git repository with no contract, Claude finishes your task and offers once to sign it. Say yes: every turn checks protected paths, and every commit is re-proved.
17
+
18
+ Or from the command line:
19
+
9
20
  ```sh
10
21
  # 1 · try it (no install, no commitment): verify your latest change right now
11
22
  npx sawbuck check
@@ -13,7 +24,7 @@ npx sawbuck check
13
24
  # 2 · enforce it: attach once to this repo
14
25
  npm i -g sawbuck
15
26
  sawbuck attach # every commit (a git post-commit hook)
16
- sawbuck attach --claude # or every Claude Code turn (a project-scoped Stop hook)
27
+ sawbuck attach --claude # or protected paths every Claude Code turn, plus every commit (a project-scoped Stop hook)
17
28
  ```
18
29
 
19
30
  **Zero-config:** it auto-detects your test command, so there is nothing to write to start.
@@ -38,12 +49,12 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
38
49
 
39
50
  ## What it does
40
51
 
41
- - **Verifies the win.** `check` re-runs your metric from the source diff alone. A faked green (a deleted, weakened, or skipped test; a mocked grader; an edited golden file; a loosened config) reads `gamed`. Deterministic, zero-LLM, $0, and it cannot be argued with because it never reads the trajectory. Red-teamed across 9 ecosystems and 26 adversarial scenarios ([promptwheel/bench/REDTEAM.md](https://github.com/promptwheel-ai/promptwheel/blob/main/bench/REDTEAM.md)).
52
+ - **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` is recorded. 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)).
42
53
  - **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.
43
54
  - **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.
44
55
  - **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. On-demand, so it is quiet until you go looking.
45
56
  - **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
46
- - **Clean on and off.** the default `attach` touches nothing in your worktree (the hook lives in `.git/`, the record is excluded locally); `--claude` writes one git-excluded settings file; `detach` is a true inverse. `trace` shows every byte it placed, and `detach --verify` proves the repo came back unchanged.
57
+ - **Clean on and off.** the default `attach` touches nothing in your worktree (the hook lives in `.git/`, the record is excluded locally); `--claude` writes one git-excluded settings file; `detach` removes what it wired. Signed contract files stay because they are yours to review; `--purge` deletes the record. `trace` shows every byte it placed, and `detach --verify` proves the wiring is gone.
47
58
 
48
59
  ## Commands
49
60
 
@@ -65,7 +76,7 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
65
76
 
66
77
  ## Give your agent the contract
67
78
 
68
- `sawbuck init` writes this block into your `AGENTS.md` for you (and `CLAUDE.md` / `.cursorrules` if present; `--no-agent-block` to skip), and `sawbuck attach --claude` enforces the same thing every turn via the hook. Here it is to paste yourself if you want:
79
+ `sawbuck init` writes this block into your `AGENTS.md` for you (and `CLAUDE.md` / `.cursorrules` if present; `--no-agent-block` to skip), and `sawbuck attach --claude` 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:
69
80
 
70
81
  This repo is under a Sawbuck contract: a change is done only when the win survives verification
71
82
  from the source alone. Before finishing, run `sawbuck check`. If it reads `gamed`, the green came
@@ -73,7 +84,11 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
73
84
  Run `sawbuck context` first to see what broke in these files before (reverts, weakened tests,
74
85
  fragile spots). Never skip, weaken, or delete a test to go green.
75
86
 
76
- **Measured, not assumed.** On a scenario whose natural fix sits inside a protected module, driven past two forced compactions (and again on Haiku, and under "urgent hotfix" pressure): with no rule the agent edited the protected file 17 of 17 times; with a rule in any form, including a mandate, 0 of 45. The model honors a rule it was told. The gate is for the rule nobody told it, the session that outlived the summary, and the proof that it held: `attach --claude` re-reads the mandates from the repo on every turn, re-impresses them after a compaction (`sawbuck turn`), and blocks a turn that leaves a protected path changed. Numbers and method: [sawbuck.ai/contracts](https://sawbuck.ai/contracts).
87
+ **Rewritten history.** Logbook reads git, so a squash or a rebase takes a revert with it. `sawbuck context` also reads the reflog for commits no branch reaches any more and shows them for the files in hand, and once attached it keeps them in `.sawbuck/rewritten.jsonl` after the reflog expires. It stays local: a clone does not get it, nothing older than the reflog comes back, and work that was never committed is not in git at all. Your steering is separate: `reins` reads your agent transcripts, not git.
88
+
89
+ **Tested claims.** Every claim above, and every limit, is a test in [`test/claims.test.mjs`](test/claims.test.mjs), run on a clean account.
90
+
91
+ **Measured, not assumed.** On a scenario whose natural fix sits inside a protected module, 62 trials: 52 driven past a forced compaction (two on the default model, one on Haiku) and 10 shallow, some under "urgent hotfix" pressure. With no rule the agent edited the protected file 17 of 17 times; with a rule in any form, including a mandate, 0 of 45. Earlier scenarios did not tempt it (no rule, 0 of 17), and the wall never had to fire. The model honors a rule it was told. The gate is for the rule nobody told it, the session that outlived the summary, and the proof that it held: `attach --claude` re-reads the mandates from the repo on every turn, re-impresses them after a compaction (`sawbuck turn`), and sends back a turn that leaves a protected path changed, up to three times by default. Numbers and method: [sawbuck.ai/contracts](https://sawbuck.ai/contracts).
77
92
 
78
93
  Without a `promptwheel.config.json` the check measures your detected test command. To make it count more (coverage, lint, latency, an eval) or to protect a path with a mandate, `sawbuck init` writes the config and explains each line with evidence from your history.
79
94
 
package/contract.mjs CHANGED
@@ -72,7 +72,7 @@ export function hashOf(repo, file, { store = false } = {}) {
72
72
  // `store` keeps each file's content in the object database (see hashOf), which the baseline does.
73
73
  export function protectedDirty(repo, { store = false } = {}) {
74
74
  let changed = [];
75
- try { changed = [...new Set([...gitOut(repo, ['diff', '--name-only', 'HEAD']).split('\n'), ...gitOut(repo, ['ls-files', '--others', '--exclude-standard']).split('\n')].filter(Boolean))]; }
75
+ try { changed = [...new Set([...gitOut(repo, ['diff', '--name-only', '--no-renames', 'HEAD']).split('\n'), ...gitOut(repo, ['ls-files', '--others', '--exclude-standard']).split('\n')].filter(Boolean))]; }
76
76
  catch { return {}; }
77
77
  const res = mandatesInForce(repo).flatMap((m) => pathsOf(m).map(globToRe));
78
78
  const out = {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.35",
4
- "description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves every win from the agent's source edits alone, so a green that came from editing the test, grader, or config is caught and sent back. Works in Claude Code, git hooks, and CI, with Codex support in beta.",
3
+ "version": "0.1.37",
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 support in beta.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "engines": {
@@ -34,10 +34,11 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@promptwheel/logbook": "0.9.10",
37
- "promptwheel": "0.7.12"
37
+ "promptwheel": "0.7.13"
38
38
  },
39
39
  "files": [
40
40
  "sawbuck.mjs",
41
+ "rewrites.mjs",
41
42
  "contract-cmd.mjs",
42
43
  "bins.mjs",
43
44
  "contract.mjs",
package/reflect.mjs CHANGED
@@ -131,7 +131,7 @@ function appendActivity(v, rep, standing = false) {
131
131
  const why = k === 'gamed' ? (gm?.gamingReason || 'the win came from editing the goalposts')
132
132
  : k === 'regression' ? 'a guarded metric regressed beyond noise'
133
133
  : k === 'allowed' ? ('declared intentional: ' + (rep.allowed?.reason || ''))
134
- : k === 'verified' ? 'the source earned the green'
134
+ : k === 'verified' ? 'the check passed'
135
135
  : 'could not certify a pass';
136
136
  // `standing`: this check re-proved the span of a gamed verdict that is still up, not a new catch
137
137
  const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160), by: by || null, ...(standing ? { standing: true } : {}) };
package/rewrites.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // What a rewrite erased. Logbook reads history, so a squash, a rebase or a force push takes the lesson with
2
+ // it: a reverted attempt stops being there to find. Git keeps those commits in the reflog for a while, so
3
+ // the commits a branch no longer reaches are read from there, kept in .sawbuck/rewritten.jsonl once this repo
4
+ // is attached (the reflog expires, the ledger does not), and surfaced by `context` for the paths in hand.
5
+ // Reads git only, no model, no network. The ledger is local: a clone does not get it. Never writes in a repo
6
+ // sawbuck is not attached to.
7
+ import { execFileSync } from 'node:child_process';
8
+ import { existsSync, readFileSync, appendFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ const MAX = 300;
12
+ const g = (repo, ...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 }); } catch { return ''; } };
13
+
14
+ function ledger(repo) {
15
+ try { return readFileSync(join(repo, '.sawbuck', 'rewritten.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); } catch { return []; }
16
+ }
17
+
18
+ // commits the reflog remembers and no ref reaches. An amend or a rebase replays the same subject, so a commit
19
+ // whose subject is still reachable was not lost, only rewritten.
20
+ function fromReflog(repo) {
21
+ const raw = g(repo, 'log', '--reflog', '--not', '--all', '-n', String(MAX), '--format=%x00%H%x09%cI%x09%s', '--name-only', '--no-renames');
22
+ if (!raw.trim()) return [];
23
+ const kept = new Set(g(repo, 'log', '--all', '-n', '5000', '--format=%s').split('\n'));
24
+ const out = [];
25
+ for (const chunk of raw.split('\0').slice(1)) {
26
+ const [head, ...rest] = chunk.split('\n');
27
+ const [sha, at, ...s] = head.split('\t');
28
+ const subject = s.join('\t');
29
+ if (!sha || kept.has(subject)) continue;
30
+ out.push({ sha, at, subject, revert: /^revert\b/i.test(subject), files: rest.filter(Boolean).slice(0, 50) });
31
+ }
32
+ return out;
33
+ }
34
+
35
+ export function erasedCommits(repo) {
36
+ const known = ledger(repo);
37
+ const seen = new Set(known.map((e) => e.sha));
38
+ const fresh = fromReflog(repo).filter((e) => !seen.has(e.sha));
39
+ if (fresh.length && existsSync(join(repo, '.sawbuck', 'state.json'))) {
40
+ try { appendFileSync(join(repo, '.sawbuck', 'rewritten.jsonl'), fresh.map((e) => JSON.stringify(e)).join('\n') + '\n'); } catch {}
41
+ }
42
+ return [...known, ...fresh];
43
+ }
44
+
45
+ // the lines `context` adds for the paths in hand: reverts first, then the checks that caught something here
46
+ export function erasedBlock(repo, changed) {
47
+ const touched = new Set(changed);
48
+ const hits = erasedCommits(repo).filter((e) => (e.files || []).some((f) => touched.has(f)))
49
+ .sort((a, b) => (b.revert - a.revert) || String(b.at).localeCompare(String(a.at))).slice(0, 3);
50
+ let caught = [];
51
+ try {
52
+ caught = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l))
53
+ .filter((e) => e.kind === 'gamed' && touched.has(e.file)).slice(-2).reverse();
54
+ } catch {}
55
+ if (!hits.length && !caught.length) return '';
56
+ const lines = ['not in git history any more (recovered locally, leads):'];
57
+ for (const e of hits) lines.push(` - ${e.revert ? 'reverted' : 'erased by a rewrite'}: ${e.subject} (${e.sha.slice(0, 12)}, ${String(e.at).slice(0, 10)})`);
58
+ for (const e of caught) lines.push(` - caught as gamed on ${e.file} (${e.sha}, ${String(e.at).slice(0, 10)})`);
59
+ return lines.join('\n');
60
+ }
package/sawbuck-hook.mjs CHANGED
@@ -50,7 +50,9 @@ const writeRec = (r) => { try { mkdirSync(stDir, { recursive: true }); writeFile
50
50
  const logActivity = (ev) => { try { mkdirSync(stDir, { recursive: true }); appendFileSync(join(stDir, 'activity.jsonl'), JSON.stringify({ at: new Date().toISOString(), ...ev }) + '\n'); } catch {} };
51
51
  const hasCommit = (ref) => { try { execFileSync('git', ['-C', repo, 'cat-file', '-e', `${ref}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
52
52
  let rec = readRec();
53
- if (head && st.lastSha && head !== st.lastSha) {
53
+ // the first turn after attach has no lastSha yet: its baseline is what that turn started from
54
+ const from = st.lastSha || (st.turnBaseline && st.turnBaseline.sha) || '';
55
+ if (head && from && head !== from) {
54
56
  let standing = !!(rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.sha !== head.slice(0, 8));
55
57
  if (standing && !hasCommit(rec.latest.base)) {
56
58
  // the span can no longer be re-proved (a rebase, a squash, a pruned branch took its base away):
@@ -61,7 +63,7 @@ if (head && st.lastSha && head !== st.lastSha) {
61
63
  writeRec(rec);
62
64
  standing = false;
63
65
  }
64
- node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', standing ? rec.latest.base : st.lastSha, '--head', head, ...(by ? ['--by', by] : []), ...(userMode ? ['--no-auto'] : [])]);
66
+ node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', standing ? rec.latest.base : from, '--head', head, ...(by ? ['--by', by] : []), ...(userMode ? ['--no-auto'] : [])]);
65
67
  rec = readRec();
66
68
  }
67
69
  st.lastSha = head || st.lastSha;
@@ -81,10 +83,10 @@ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFile
81
83
  // edit COMMITTED since the baseline's commit counts too: a commit mid-turn does not walk through.
82
84
  if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
83
85
  const now = new Date().toISOString();
84
- const dirty = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
86
+ const dirty = [...new Set([...git('diff', '--name-only', '--no-renames', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
85
87
  const base = (st.turnBaseline && typeof st.turnBaseline === 'object') ? st.turnBaseline : null;
86
88
  const baseline = (base && base.files && typeof base.files === 'object') ? base.files : {};
87
- const committedSince = (base && base.sha && head && base.sha !== head) ? git('diff', '--name-only', base.sha, 'HEAD').split('\n').filter(Boolean) : [];
89
+ const committedSince = (base && base.sha && head && base.sha !== head) ? git('diff', '--name-only', '--no-renames', base.sha, 'HEAD').split('\n').filter(Boolean) : [];
88
90
  const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
89
91
  const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
90
92
  const changed = [...new Set([...dirty.filter((f) => !untouched(f)), ...committedSince])];
@@ -122,7 +124,16 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
122
124
  const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
123
125
  const accept = committedHit ? ' To accept this change on purpose, change the mandate in the contract or revert the path.' : '';
124
126
  const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach --claude again, which upgrades the hooks.)';
125
- logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
127
+ // one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
128
+ // a block must not replace a gamed verdict that is still standing.
129
+ if (st.mandateCountedPrompt !== promptId) {
130
+ st.mandateCountedPrompt = promptId;
131
+ const r = readRec() || { regressions: 0, gamed: 0, latest: null };
132
+ r.mandates = (r.mandates || 0) + 1;
133
+ r.lastMandate = { name: hits[0].name, file: hits[0].files[0], at: now };
134
+ writeRec(r);
135
+ logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
136
+ }
126
137
  if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
127
138
  st.mandateBlocks = (st.mandateBlocks || 0) + 1;
128
139
  saveState();
@@ -199,7 +210,7 @@ function ensureExcluded(r) {
199
210
  const f = resolve(r, rel);
200
211
  // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
201
212
  // the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
202
- const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/*\n!.promptwheel/packs/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n# <<< sawbuck <<<\n';
213
+ const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/*\n!.promptwheel/packs/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n.claude/settings.local.json\n# <<< sawbuck <<<\n';
203
214
  const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
204
215
  const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
205
216
  const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
package/sawbuck.mjs CHANGED
@@ -13,8 +13,9 @@
13
13
  // proves the repo came back clean; `doctor` checks the attachment still resolves.
14
14
 
15
15
  import { execFileSync } from 'node:child_process';
16
+ import { erasedBlock } from './rewrites.mjs';
16
17
  import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
17
- import { join, dirname, resolve } from 'node:path';
18
+ import { basename, join, dirname, resolve } from 'node:path';
18
19
  import { fileURLToPath } from 'node:url';
19
20
  import { homedir } from 'node:os';
20
21
  import { recall as memRecall, conform as memConform } from './memory.mjs';
@@ -170,11 +171,14 @@ switch (cmd) {
170
171
  if (json) { out(JSON.stringify(rep)); process.exit(code); }
171
172
  const m = (rep.metrics || [])[0];
172
173
  for (const md of rep.mandates || []) out(`🐴 ⛔ mandate "${md.name}" violated${md.reason ? ': ' + md.reason : ''} (touched: ${(md.files || []).join(', ')})${md.removed ? ' [deleted in this same change; a mandate stays in force for the change that removes it]' : ''}`);
173
- if (rep.verdict === 'pass') out(`🐴 ✓ verified: your ${where} change earned it. ${m ? '"' + m.name + '"' : 'The metric'} held, and the source carried the win (it survives a source-only re-run).`);
174
+ const win = (rep.metrics || []).find((x) => x.status === 'improved' && !x.tripwire);
175
+ if (rep.verdict === 'pass' && win) out(`🐴 ✓ verified: your ${where} change earned it. "${win.name}" improved, and the source carried the win (it survives a source-only re-run).`);
176
+ else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
174
177
  else if (rep.verdict === 'gamed') 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.');
175
178
  else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a hard rule in the contract was broken, however green the rest is.');
176
179
  else if (rep.verdict === 'fail') out(`🐴 ✗ regression: a guarded metric got worse (${(rep.metrics || []).filter((x) => x.guard && !x.ok).map((x) => x.name).join(', ') || 'a guard'}).`);
177
180
  else out('🐴 ≈ inconclusive: the check ran but could not measure your metric (it did not run in isolation). Point it at your real test command: `sawbuck init`, or set setup/linkDirs in promptwheel.config.json.');
181
+ 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.');
178
182
  if (rep.gate && rep.gate.changed) out(`🐴 ⚠ the gate definition changed in this change (fingerprint ${rep.gate.fingerprint.base} -> ${rep.gate.fingerprint.head}): a metric, guard, evaluatorPath or mandate was added, removed or edited.`);
179
183
  process.exit(code);
180
184
  }
@@ -186,7 +190,7 @@ switch (cmd) {
186
190
  if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
187
191
  const ni = rest.indexOf('-n');
188
192
  const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
189
- const icon = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' };
193
+ const icon = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
190
194
  const recent = lines.slice(-n).reverse();
191
195
  out(`sawbuck activity (last ${recent.length}):`);
192
196
  for (const l of recent) {
@@ -340,10 +344,12 @@ switch (cmd) {
340
344
  case 'statusline': {
341
345
  // compact status-line segment for the cwd repo. Never throws; fast.
342
346
  try {
343
- const repo = process.cwd();
344
- if (existsSync(join(repo, '.git'))) {
347
+ const repo = gitTop(process.cwd());
348
+ if (!repo) process.stdout.write('🐴 sawbuck · no repo here');
349
+ if (repo) {
350
+ const name = basename(repo);
345
351
  let caught = 0;
346
- try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0); } catch {}
352
+ try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
347
353
  // the last verdict from the activity feed (includes verified passes), so you see it working each turn
348
354
  let last = '';
349
355
  try { const L = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').trim().split('\n'); const e = JSON.parse(L[L.length - 1]); last = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' }[e.kind] || ''; } catch {}
@@ -351,20 +357,22 @@ switch (cmd) {
351
357
  const active = existsSync(join(repo, '.sawbuck', 'state.json'));
352
358
  const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
353
359
  const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
354
- const seg = caught > 0 ? `🐴 sawbuck · ${caught} caught` : active ? `🐴 sawbuck · watching` : armed ? `🐴 sawbuck · ready` : mem ? `🐴 sawbuck · memory` : '';
355
- const base = seg ? (last ? `${seg} · ${last}` : seg) : '';
360
+ const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
361
+ const seg = label ? `🐴 sawbuck · ${label}` : '';
362
+ const named = label ? `🐴 sawbuck · ${name} · ${label}` : ''; // which repo it detected, in the default line
363
+ const base = named ? (last ? `${named} · ${last}` : named) : '';
356
364
  // live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
357
365
  let st = '';
358
366
  try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) { const p = Math.round(((sj.ema != null ? sj.ema : sj.steeringRate) || 0) * 100); st = `${p >= 50 ? '✊' : '🐎'} steer ${p}%`; } } catch {}
359
367
  // configurable template: SAWBUCK_STATUSLINE picks which segments show and in what order
360
- // (tokens {status} {sawbuck} {verdict} {steer} plus any literal text); empty tokens collapse
368
+ // (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
361
369
  // so you never get a dangling separator. Unset = the default composition. Mix in other
362
370
  // tools (git branch, model, your own adapters) by composing in your statusline script.
363
371
  const tpl = process.env.SAWBUCK_STATUSLINE;
364
372
  let outp;
365
373
  if (tpl && tpl.trim()) {
366
- const tok = { status: base, sawbuck: seg, verdict: last, steer: st };
367
- outp = tpl.replace(/\{(status|sawbuck|verdict|steer)\}/g, (_, k) => tok[k] || '')
374
+ const tok = { status: base, sawbuck: seg, repo: label ? name : '', verdict: last, steer: st };
375
+ outp = tpl.replace(/\{(status|sawbuck|repo|verdict|steer)\}/g, (_, k) => tok[k] || '')
368
376
  .replace(/\s*·\s*·\s*/g, ' · ').replace(/^\s*·\s*/, '').replace(/\s*·\s*$/, '').replace(/\s{2,}/g, ' ').trim();
369
377
  } else {
370
378
  outp = [base, st].filter(Boolean).join(' · ');
@@ -383,14 +391,17 @@ switch (cmd) {
383
391
  // "nothing notable" every turn would be noise). `speak` forces a reply for the explicit command.
384
392
  function contextBlock(repo, speak) {
385
393
  const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
386
- let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
394
+ // new files count: re-creating what was reverted is the case the history is for
395
+ let changed = [...new Set([...g('diff', '--name-only', 'HEAD').split('\n'), ...g('ls-files', '--others', '--exclude-standard').split('\n')])].filter(Boolean);
387
396
  if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
388
397
  if (!changed.length) return speak ? '🐴 sawbuck: nothing changed to look up (no working edits, and no previous commit to read).' : '';
389
398
  const fileArgs = changed.flatMap((f) => ['--file', f]);
390
399
  let o = '';
391
400
  try { o = execFileSync(process.execPath, [join(HERE, 'rank.mjs'), '--repo', repo, ...fileArgs, '--top', '3'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); }
392
401
  catch (e) { o = (e.stdout || '').toString(); }
393
- if (/relevant history/.test(o)) return o.trimEnd();
402
+ const erased = erasedBlock(repo, changed);
403
+ if (/relevant history/.test(o)) return [o.trimEnd(), erased].filter(Boolean).join('\n');
404
+ if (erased) return erased;
394
405
  return speak ? '🐴 sawbuck: no notable history for the files you are touching (a clean trail).' : '';
395
406
  }
396
407
 
@@ -515,6 +526,12 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
515
526
  }
516
527
  function wireClaude(repo, opts) {
517
528
  const { user, shared } = opts;
529
+ // user-level hooks already cover every repo; a second set would run each check twice and spend the
530
+ // send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
531
+ if (!user && !shared) {
532
+ const uf = claudeFile(repo, { user: true }).f;
533
+ try { if (readFileSync(uf, 'utf8').includes('sawbuck.mjs')) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add`); return; } } catch {}
534
+ }
518
535
  const { dir, f } = claudeFile(repo, opts);
519
536
  const dirExisted = existsSync(dir);
520
537
  const fileExisted = existsSync(f);
@@ -635,6 +652,8 @@ function purgeRecord(repo) {
635
652
  const p = join(repo, d);
636
653
  try { if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); n++; out(`sawbuck: purged ${p}`); } } catch {}
637
654
  }
655
+ const outcomes = join(repo, '.promptwheel', 'outcomes.jsonl'); // the rest of .promptwheel/ is the signed contract: yours to keep or delete
656
+ try { if (existsSync(outcomes)) { rmSync(outcomes, { force: true }); n++; out(`sawbuck: purged ${outcomes}`); } } catch {}
638
657
  if (!n) out('sawbuck: no record to purge');
639
658
  }
640
659
  // is `sawbuck` resolvable on PATH? (SAWBUCK_NO_PATH_CHECK forces the strict/offline answer)
package/status.mjs CHANGED
@@ -22,17 +22,17 @@ const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } ca
22
22
  const readLines = (p) => { try { return readFileSync(p, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
23
23
  const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
24
24
 
25
- const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' };
25
+ const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
26
26
 
27
27
  // --- 1) the outcome record: what the check has done here -------------------
28
28
  // results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
29
29
  // the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
30
30
  const results = readJSON(join(repo, '.sawbuck', 'results.json')) || {};
31
31
  const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
32
- const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0 };
32
+ const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0, mandate: 0 };
33
33
  for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
34
34
  const seen = feed.length;
35
- const catchesAllTime = (results.regressions || 0) + (results.gamed || 0);
35
+ const catchesAllTime = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
36
36
  const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
37
37
  const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
38
38
  // the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file; the
@@ -89,6 +89,7 @@ if (seen || catchesAllTime) {
89
89
  const parts = [
90
90
  tally.verified ? `${ICON.verified} ${tally.verified} verified` : '',
91
91
  tally.gamed ? `${ICON.gamed} ${tally.gamed} gamed` : '',
92
+ tally.mandate ? `${ICON.mandate} ${tally.mandate} protected path${tally.mandate === 1 ? '' : 's'}` : '',
92
93
  tally.regression ? `${ICON.regression} ${tally.regression} regression${tally.regression === 1 ? '' : 's'}` : '',
93
94
  tally.inconclusive ? `${ICON.inconclusive} ${tally.inconclusive} inconclusive` : '',
94
95
  tally.allowed ? `${ICON.allowed} ${tally.allowed} allowed` : '',