sawbuck 0.1.30 → 0.1.32

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
@@ -25,7 +25,7 @@ The per-turn signal a loop consumes. The check (`run`) exits:
25
25
  | `0` | `pass` | The win is real: it survived a source-only re-proof and no guard regressed. | Keep the change. |
26
26
  | `1` | `fail` | A guarded metric regressed beyond the measured noise band. | Revert. |
27
27
  | `2` | `gamed` | A metric improved only by editing the goalposts (test, grader, golden, config), not the source. | Revert and flag. |
28
- | `3` | `inconclusive` | Nothing could be certified: a guard measured nothing, or the delta sat inside noise. | Do not trust the green; do not keep on this signal alone. |
28
+ | `3` | `inconclusive` | Nothing could be certified: a guard never ran (inert) and no metric was measured to improve, or a win's source-only re-run could not be applied, so the win could not be re-proved either way. | Do not trust the green; do not keep on this signal alone. |
29
29
 
30
30
  The agent consumes this as a per-turn reward it cannot inflate. The harness consumes the same exit as a merge gate the agent cannot self-clear.
31
31
 
@@ -67,7 +67,7 @@ Fixed vocabularies (a conforming implementation uses exactly these):
67
67
  - `metric.status` ∈ `improved | regressed | unchanged | inconclusive | unmeasurable`
68
68
  - `direction` ∈ `up | down | pass` (the direction that counts as better; `pass` means a 0/1 gate)
69
69
 
70
- `retained` is the fraction of a metric's gain that survived the source-only re-run (§3). `noise` is the measured jitter band as a single number, across the top-level `repeat` runs; a delta inside it is `inconclusive`, never a regression. `confidence` (`unverified | low | medium | high`) and `sourceOnly` (the metric's value when only source edits are replayed, §3) are informational fields the reference implementation adds; a minimal conforming report may omit them. A consumer validates a report with `conformsToContract()` from the `promptwheel` package (see INTEGRATE.md) or `promptwheel conformance`.
70
+ `retained` is the fraction of a metric's gain that survived the source-only re-run (§3). `noise` is the measured jitter band as a single number, across the top-level `repeat` runs; a delta inside it leaves that metric `inconclusive` (neither a win nor a regression; the verdict is not failed by it). `confidence` (`none | unverified | low | medium | high`, `none` for an unmeasurable metric) and `sourceOnly` (the metric's value when only source edits are replayed, §3) are informational fields the reference implementation adds; a minimal conforming report may omit them. A consumer validates a report with `conformsToContract()` from the `promptwheel` package (see INTEGRATE.md) or `promptwheel conformance`.
71
71
 
72
72
  `gate` (optional) is the gate-integrity record. `gate.fingerprint` is a truncated SHA-256 of the normalized gate definition at `base` and `head`, so a changed gate is visible in the audit record. `gate.weakened`, present only when it happened, lists guards the change dropped (removed, or set to `guard:false`) that would have failed at `head`. A non-empty `gate.weakened` forces `verdict: gamed`: an agent cannot earn a pass by disabling the guard it is being judged by.
73
73
 
@@ -77,7 +77,7 @@ This is the part that makes "moving the goalposts" objective rather than a judgm
77
77
 
78
78
  A win is **`gamed`** when it does not survive a **source-only re-run**: revert every non-source change the agent made, re-measure, and see how much of the gain remains (`retained`). If `retained` falls below `gamingThreshold` (default `0.5`), the verdict is `gamed`.
79
79
 
80
- - **Non-source** (reverted before the re-run): tests, specs, fixtures, snapshots and golden files, graders, config, lockfiles, CI definitions, build gates, and any path the repo opts into via `evaluatorPaths`.
80
+ - **Non-source** (reverted before the re-run): tests, specs, fixtures, snapshots and golden files, graders, config, plus any path declared in `evaluatorPaths`.
81
81
  - **Source**: everything else, the code the change was supposed to be about.
82
82
  - **`evaluatorPaths`**: an opt-in allowlist a repo declares for its own gate files (a threshold file, a budget, a ruleset), because a generic name is a classifier risk and only the repo owner knows which files are the goalposts.
83
83
 
package/README.md CHANGED
@@ -34,16 +34,16 @@ $ sawbuck check # after fixing the source and restoring the test
34
34
  🐴 ✓ verified: your working change earned it. "tests_pass" held, and the source carried the win.
35
35
  ```
36
36
 
37
- Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `64` could not run (not a git repo, no commit, no test command).
37
+ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `64` could not run (not a git repo, no commit); no detectable test command reads `3` inconclusive.
38
38
 
39
39
  ## What it does
40
40
 
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 language ecosystems and 26 adversarial scenarios ([promptwheel/bench/REDTEAM.md](https://github.com/promptwheel-ai/promptwheel/blob/main/bench/REDTEAM.md)).
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)).
42
42
  - **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
43
  - **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
44
  - **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
45
  - **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
46
- - **Clean on and off.** `attach` touches nothing in your worktree (the hook lives in `.git/`, the record is excluded locally); `detach` is a true inverse. `trace` shows every byte it placed, and `detach --verify` proves the repo came back unchanged.
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.
47
47
 
48
48
  ## Commands
49
49
 
package/contract.mjs CHANGED
@@ -59,24 +59,32 @@ export function protectedAmong(repo, paths) {
59
59
  return paths.filter((f) => res.some((re) => re.test(f)));
60
60
  }
61
61
 
62
- // a working-tree file's content hash as git would store it; 'deleted' when it is gone
63
- export function hashOf(repo, file) {
64
- try { return gitOut(repo, ['hash-object', '--', file]).trim(); } catch { return 'deleted'; }
62
+ // a working-tree file's content hash as git would store it; 'deleted' when it is gone. With `store`,
63
+ // the content is also written into the object database (`hash-object -w`), so a person's edit that
64
+ // the baseline recorded can be restored by hash later even after the agent overwrote the file.
65
+ export function hashOf(repo, file, { store = false } = {}) {
66
+ try { return gitOut(repo, ['hash-object', ...(store ? ['-w'] : []), '--', file]).trim(); } catch { return 'deleted'; }
65
67
  }
66
68
 
67
69
  // the protected paths that are dirty right now, each with its content hash: the baseline `turn`
68
70
  // records when a prompt begins, so the Stop hook counts only what the turn itself changed and leaves
69
71
  // a person's own pre-existing edits alone. {} when nothing protected is dirty (or not a git repo).
70
- export function protectedDirty(repo) {
72
+ // `store` keeps each file's content in the object database (see hashOf), which the baseline does.
73
+ export function protectedDirty(repo, { store = false } = {}) {
71
74
  let changed = [];
72
75
  try { changed = [...new Set([...gitOut(repo, ['diff', '--name-only', 'HEAD']).split('\n'), ...gitOut(repo, ['ls-files', '--others', '--exclude-standard']).split('\n')].filter(Boolean))]; }
73
76
  catch { return {}; }
74
77
  const res = mandatesInForce(repo).flatMap((m) => pathsOf(m).map(globToRe));
75
78
  const out = {};
76
- for (const f of changed) if (res.some((re) => re.test(f))) out[f] = hashOf(repo, f);
79
+ for (const f of changed) if (res.some((re) => re.test(f))) out[f] = hashOf(repo, f, { store });
77
80
  return out;
78
81
  }
79
82
 
83
+ // the blob hash a tracked path has at `ref` ('' when the path is not there)
84
+ export function blobAt(repo, ref, file) {
85
+ try { return gitOut(repo, ['rev-parse', `${ref}:${file}`]).trim(); } catch { return ''; }
86
+ }
87
+
80
88
  // the same glob dialect as promptwheel's evaluatorPaths / mandates (ported verbatim): a bare name
81
89
  // matches that basename at any depth; a pattern with "/" is anchored at the repo root; "*" stops at
82
90
  // "/", "**" crosses it, "?" is one char; a leading "/" anchors to the root explicitly.
package/debt.mjs CHANGED
@@ -33,7 +33,11 @@ const bare = live.filter((it) => !it.documented && it.content).length;
33
33
  if (since && typeof since === 'string') {
34
34
  const norm = (s) => String(s).trim().replace(/\s+/g, ' ');
35
35
  // the delta base: the raw suppression set at REF (the adapter's ref mode), subtracted from HEAD.
36
- const base = new Set((memDebt(repo, { ref: since }) || []).map((r) => r.file + '\u0000' + norm(r.content)));
36
+ // null = the ref could not be read (not fetched, a typo): unmeasurable is NOT an empty base, or
37
+ // every pre-existing suppression would read as newly added and a --fail-on gate would fail falsely.
38
+ const baseRows = memDebt(repo, { ref: since });
39
+ if (baseRows === null) { console.error(`sawbuck debt: could not read the suppression ledger at ${since} (is the ref fetched? in GitHub Actions use actions/checkout with fetch-depth: 0)`); process.exit(1); }
40
+ const base = new Set(baseRows.map((r) => r.file + '\u0000' + norm(r.content)));
37
41
  const added = live.filter((it) => it.content && !base.has(it.file + '\u0000' + norm(it.content)));
38
42
  const skips = added.filter((it) => it.testSkip).length, undoc = added.filter((it) => !it.documented).length;
39
43
  // the gate: fail (exit 1) when the branch adds the kind of debt --fail-on names.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
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, Codex, git hooks, and CI.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@promptwheel/logbook": "^0.9.6",
37
- "promptwheel": "^0.7.5"
37
+ "promptwheel": "^0.7.9"
38
38
  },
39
39
  "files": [
40
40
  "sawbuck.mjs",
package/reflect.mjs CHANGED
@@ -30,6 +30,7 @@ const repo = flag('--repo', process.cwd());
30
30
  const base = flag('--base', 'HEAD~1');
31
31
  const head = flag('--head', 'HEAD');
32
32
  const by = flag('--by', ''); // the agent that authored the change (Claude-first: the Claude hook passes 'claude'); empty = unattributed (raw-git path)
33
+ const noAuto = process.argv.includes('--no-auto'); // user-level wiring: measure only where a contract exists, never auto-gate a stranger's suite
33
34
 
34
35
  const git = (...a) => execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim();
35
36
  // backstop so the check can never hang a commit/turn; generous default, overridable.
@@ -42,7 +43,7 @@ const runNode = (file, a) => execFileSync(process.execPath, [file, ...a], { cwd:
42
43
  function check() {
43
44
  const parse = (out) => { const i = out.indexOf('{'); return i < 0 ? null : JSON.parse(out.slice(i)); };
44
45
  try {
45
- return parse(runNode(PW, ['run', '--base', base, '--head', head, '--json']));
46
+ return parse(runNode(PW, ['run', '--base', base, '--head', head, '--json', ...(noAuto ? ['--no-auto'] : [])]));
46
47
  } catch (e) {
47
48
  return parse((e.stdout || '').toString());
48
49
  }
@@ -51,7 +52,33 @@ function check() {
51
52
  const report = check();
52
53
  if (!report || !report.verdict) process.exit(0); // couldn't measure → nothing to learn
53
54
  const verdict = report.verdict; // engine contract: 'gamed' | 'fail' | ...
54
- appendActivity(verdict, report); // the event feed logs EVERY measurable check, so you can watch it work (verified too, not only catches)
55
+ // the results record, and the base this run measured from (a full sha), so a gamed verdict can be
56
+ // held against the SAME span until it re-proves: the hook re-runs base..HEAD, not last-commit..HEAD
57
+ const recDir = join(repo, '.sawbuck');
58
+ const recPath = join(recDir, 'results.json');
59
+ let rec = { regressions: 0, gamed: 0, latest: null };
60
+ try { if (existsSync(recPath)) rec = JSON.parse(readFileSync(recPath, 'utf8')); } catch {}
61
+ let baseSha = ''; try { baseSha = git('rev-parse', `${base}^{commit}`); } catch {}
62
+ const standing = !!(rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.base === baseSha);
63
+ const writeRec = () => { try { mkdirSync(recDir, { recursive: true }); writeFileSync(recPath, JSON.stringify(rec, null, 2) + '\n'); } catch {} };
64
+ appendActivity(verdict, report, standing); // the event feed logs EVERY measurable check, so you can watch it work (verified too, not only catches)
65
+ if (standing) {
66
+ let headSha = ''; try { headSha = git('rev-parse', head); } catch {}
67
+ if (verdict === 'pass') {
68
+ // the span the gamed verdict was measured over now passes from the source: the wall comes down
69
+ rec.latest = { sha: headSha.slice(0, 8), kind: 'verified', base: baseSha, file: rec.latest.file, reason: 'the source earned the green over the span the gamed verdict was measured on', at: new Date().toISOString(), by: by || null };
70
+ writeRec();
71
+ process.exit(0);
72
+ }
73
+ // gamed again, a regression on top, or a span that cannot be certified: the source has still not
74
+ // earned the green over the span the verdict was measured on, so the gamed record holds, advanced
75
+ // to this head (the Stop hook keeps the wall up), its file and attribution untouched (a commit
76
+ // the person made in between is not the catch), and nothing is counted or noted a second time
77
+ rec.latest = { ...rec.latest, sha: headSha.slice(0, 8), at: new Date().toISOString(), standingVerdict: verdict };
78
+ writeRec();
79
+ process.stderr.write(`sawbuck: the gamed verdict on ${rec.latest.file} still stands (${verdict} over the same span)\n`);
80
+ process.exit(0);
81
+ }
55
82
  if (verdict !== 'gamed' && verdict !== 'fail') process.exit(0); // only breakage is worth recording
56
83
 
57
84
  // map the engine verdict to Sawbuck's plain outcome words
@@ -73,17 +100,15 @@ const reason = kind === 'gamed'
73
100
  // --- 3) record the finding in memory (unreviewed, machine-authored note) ----
74
101
  // The Memory Contract's `record` capability, through the adapter (no direct engine shell here).
75
102
  // Kept simple: a commit-attached digest note. A later pass could add --span grounding
76
- // and true coupling (the fix diff -> "change X also needs Y").
77
- memRecord(repo, { sha, reason: reason.slice(0, 280), side: 'diff', evidenceFile, by: 'sawbuck' });
103
+ // and true coupling (the fix diff -> "change X also needs Y"). A gamed verdict still standing
104
+ // over the same span is the same catch, not a new one: no second note, no second tick.
105
+ const repeatOfStanding = standing && kind === 'gamed';
106
+ if (!repeatOfStanding) memRecord(repo, { sha, reason: reason.slice(0, 280), side: 'diff', evidenceFile, by: 'sawbuck' });
78
107
 
79
108
  // --- 4) tick the results record (the visible proof-of-work) -----------------
80
- const recDir = join(repo, '.sawbuck');
81
- const recPath = join(recDir, 'results.json');
82
- let rec = { regressions: 0, gamed: 0, latest: null };
83
- try { if (existsSync(recPath)) rec = JSON.parse(readFileSync(recPath, 'utf8')); } catch {}
84
- if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1;
85
- rec.latest = { sha: sha.slice(0, 8), kind, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString(), by: by || null };
86
- try { mkdirSync(recDir, { recursive: true }); writeFileSync(recPath, JSON.stringify(rec, null, 2) + '\n'); } catch {}
109
+ if (!repeatOfStanding) { if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1; }
110
+ 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 };
111
+ writeRec();
87
112
 
88
113
  // --- 5) quiet by default (dispatch surfaces on cadence); one line to stderr -
89
114
  process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
@@ -91,7 +116,7 @@ process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
91
116
  // --- the activity feed: one line per check so you can watch it work, verified
92
117
  // included, not only catches. Capped, lives in the git-excluded .sawbuck/, opt
93
118
  // out with SAWBUCK_NO_LOG=1. `sawbuck log` reads it.
94
- function appendActivity(v, rep) {
119
+ function appendActivity(v, rep, standing = false) {
95
120
  if (process.env.SAWBUCK_NO_LOG) return;
96
121
  const map = { pass: 'verified', gamed: 'gamed', fail: 'regression', inconclusive: 'inconclusive' };
97
122
  const k = (v === 'pass' && rep.allowed) ? 'allowed' : map[v]; // a declared override is logged distinctly, for the audit trail
@@ -105,7 +130,8 @@ function appendActivity(v, rep) {
105
130
  : k === 'allowed' ? ('declared intentional: ' + (rep.allowed?.reason || ''))
106
131
  : k === 'verified' ? 'the source earned the green'
107
132
  : 'could not certify a pass';
108
- const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160), by: by || null };
133
+ // `standing`: this check re-proved the span of a gamed verdict that is still up, not a new catch
134
+ const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160), by: by || null, ...(standing ? { standing: true } : {}) };
109
135
  const dir = join(repo, '.sawbuck');
110
136
  const p = join(dir, 'activity.jsonl');
111
137
  try {
package/sawbuck-hook.mjs CHANGED
@@ -6,20 +6,26 @@
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 } from 'node:path';
9
+ import { join, dirname, resolve } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
 
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
13
  import { mandateHits, hashOf, protectedDirty, protectedAmong } from './contract.mjs';
14
14
  const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
15
- const repo = flag('--repo', process.cwd());
16
15
  const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
17
16
  const stop = process.argv.includes('--stop'); // wired as the Claude Code Stop hook: enforce the contract on a gamed turn
17
+ // user-level wiring passes no --repo: the hook runs in whatever cwd the session has, so it resolves the
18
+ // repository root itself (a subdirectory belongs to its repo) and leaves a non-repository alone: no
19
+ // state, no exclude block, nothing written where there is nothing to verify.
20
+ const userMode = !process.argv.includes('--repo');
21
+ const sh = (cwd, ...a) => { try { return execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
22
+ const repo = flag('--repo', '') || sh(process.cwd(), 'rev-parse', '--show-toplevel') || process.cwd();
23
+ const git = (...a) => sh(repo, ...a);
24
+ if (!git('rev-parse', '--git-dir')) process.exit(0);
18
25
  let hookInput = {};
19
26
  if (stop) { try { hookInput = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch {} } // the Stop hook's JSON on stdin (stop_hook_active, prompt_id, ...)
20
27
  ensureExcluded(repo); // keep the record out of git status under ANY attach mode (incl. --user)
21
28
 
22
- const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
23
29
  const CHECK_TIMEOUT = +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 600000; // never hang a turn
24
30
  const node = (f, a) => { try { return execFileSync(process.execPath, [f, ...a], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: CHECK_TIMEOUT, killSignal: 'SIGKILL' }); } catch (e) { return (e.stdout || '').toString(); } };
25
31
 
@@ -33,17 +39,35 @@ const stPath = join(stDir, 'state.json');
33
39
  let st = { lastSha: '', turns: 0, shownTotal: 0 };
34
40
  try { if (existsSync(stPath)) st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
35
41
 
36
- // 1) new commit(s) since last turn → run the check on them
42
+ // 1) new commit(s) since last turn → run the check on them. A gamed verdict stands until the span it
43
+ // was measured over re-proves verified: while the latest record is gamed, the re-run keeps that
44
+ // record's base, so a follow-up commit (an amend, an empty commit, unrelated work) cannot clear the
45
+ // wall without the source fix the re-proof demands. Under user-level wiring the check runs only where
46
+ // a contract exists (--no-auto): nobody's whole test suite runs three times in a repo they never set up.
37
47
  const head = git('rev-parse', 'HEAD');
48
+ const readRec = () => { try { return JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch { return null; } };
49
+ const writeRec = (r) => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(join(stDir, 'results.json'), JSON.stringify(r, null, 2) + '\n'); } catch {} };
50
+ const logActivity = (ev) => { try { mkdirSync(stDir, { recursive: true }); appendFileSync(join(stDir, 'activity.jsonl'), JSON.stringify({ at: new Date().toISOString(), ...ev }) + '\n'); } catch {} };
51
+ const hasCommit = (ref) => { try { execFileSync('git', ['-C', repo, 'cat-file', '-e', `${ref}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
52
+ let rec = readRec();
38
53
  if (head && st.lastSha && head !== st.lastSha) {
39
- node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', st.lastSha, '--head', head, ...(by ? ['--by', by] : [])]);
54
+ let standing = !!(rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.sha !== head.slice(0, 8));
55
+ if (standing && !hasCommit(rec.latest.base)) {
56
+ // the span can no longer be re-proved (a rebase, a squash, a pruned branch took its base away):
57
+ // say so in the feed, drop the record to unverified (never blocked on), and keep checking new
58
+ // commits from the last one seen, so the wall's loss is visible instead of a silent stop
59
+ logActivity({ kind: 'note', sha: head.slice(0, 8), file: rec.latest.file || '', reason: 'the base of the standing gamed verdict is gone from this clone (rebased or pruned); the record is cleared and new commits are checked from the last one seen', by: by || 'hook' });
60
+ rec.latest = { ...rec.latest, kind: 'unverified', at: new Date().toISOString(), reason: 'the base of the gamed verdict left the clone before the span re-proved from the source' };
61
+ writeRec(rec);
62
+ standing = false;
63
+ }
64
+ node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', standing ? rec.latest.base : st.lastSha, '--head', head, ...(by ? ['--by', by] : []), ...(userMode ? ['--no-auto'] : [])]);
65
+ rec = readRec();
40
66
  }
41
67
  st.lastSha = head || st.lastSha;
42
68
  st.turns += 1;
43
69
 
44
- // 2) read the record of what the check has charted
45
- let rec = null;
46
- try { rec = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch {}
70
+ // 2) the record of what the check has charted (read above, after any re-run)
47
71
  const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
48
72
  const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
49
73
 
@@ -67,26 +91,40 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
67
91
  const leftAlone = dirty.filter(untouched);
68
92
  const hits = changed.length ? mandateHits(repo, changed) : [];
69
93
  st.lastStopAt = now; // the next prompt may take a fresh baseline only because this stop happened
94
+ st.lastStopHead = head || null; // and where HEAD stood: a commit between turns is the person's, not the agent's
70
95
  if (hits.length) {
71
- // remember EVERY changed protected path (dirty or committed since the baseline) and what it holds,
72
- // uncapped, and the commit the baseline stood at, so the next prompt cannot adopt any of it
73
- const files = protectedDirty(repo);
74
- for (const f of protectedAmong(repo, committedSince)) if (!Object.prototype.hasOwnProperty.call(files, f)) files[f] = hashOf(repo, f);
75
- st.lastViolation = { at: now, sha: (base && base.sha) || null, files };
96
+ // remember EVERY protected path THIS TURN changed (dirty past the baseline, or committed since
97
+ // it) and what it holds, uncapped, plus the commit the baseline stood at, so the next prompt
98
+ // cannot adopt any of it. Only the turn's own changes: a person's file the baseline spared stays
99
+ // spared, or the next clean turn would be blocked over work that was never the agent's.
100
+ const files = {};
101
+ for (const f of protectedAmong(repo, changed)) files[f] = hashOf(repo, f);
102
+ // a flagged file the person had dirty before the prompt: keep the hash of THEIR content (the
103
+ // baseline stored it in the object database), so the block can name the exact restore and the
104
+ // next baseline can recognise the restored file as theirs
105
+ const before = {};
106
+ for (const f of Object.keys(files)) if (Object.prototype.hasOwnProperty.call(baseline, f) && baseline[f] && baseline[f] !== 'deleted') before[f] = baseline[f];
107
+ st.lastViolation = { at: now, sha: (base && base.sha) || null, files, ...(Object.keys(before).length ? { before } : {}) };
76
108
  } else if (st.lastViolation) { delete st.lastViolation; }
77
109
  saveState();
78
110
  if (hits.length) {
79
111
  const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
80
- const promptId = hookInput.prompt_id || 'none';
112
+ // the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp
113
+ // (written by `sawbuck turn --hook`), never a constant that would spend the budget for good
114
+ const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
81
115
  if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
82
116
  const desc = hits.map((h) => `"${h.name}"${h.reason ? ' (' + h.reason + ')' : ''}: ${h.files.join(', ')}${h.committed ? ' [this rule is in the committed contract; editing promptwheel.config.json in the working tree does not lift it]' : ''}`).join('; ');
83
117
  const spared = leftAlone.length ? ` Edits that were already there before this prompt were left out of this check and must stay as they are: ${leftAlone.join(', ')}.` : '';
118
+ const before = (st.lastViolation && st.lastViolation.before) || {};
119
+ const restore = Object.keys(before).length ? ' To put back what you overwrote: ' + Object.entries(before).map(([f, h]) => `restore ${f} to the content it had when the prompt began: git cat-file -p ${h} > ${f}`).join('; ') + '.' : '';
120
+ const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
121
+ const accept = committedHit ? ' To accept this change on purpose, change the mandate in the contract or revert the path.' : '';
84
122
  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.)';
85
- try { mkdirSync(stDir, { recursive: true }); appendFileSync(join(stDir, 'activity.jsonl'), JSON.stringify({ at: new Date().toISOString(), kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' }) + '\n'); } catch {}
123
+ logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
86
124
  if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
87
125
  st.mandateBlocks = (st.mandateBlocks || 0) + 1;
88
126
  saveState();
89
- process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a mandate in the contract was violated, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${upgrade}` }));
127
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a mandate in the contract was violated, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${restore}${accept}${upgrade}` }));
90
128
  process.exit(0);
91
129
  }
92
130
  saveState();
@@ -102,7 +140,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
102
140
  const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
103
141
  if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
104
142
  const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
105
- const promptId = hookInput.prompt_id || 'none';
143
+ const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
106
144
  if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
107
145
  st.shownTotal = total;
108
146
  const where = rec.latest.file;
@@ -134,8 +172,10 @@ saveState();
134
172
  // per-repo `detach` removes them.
135
173
  function ensureExcluded(r) {
136
174
  try {
137
- if (!existsSync(join(r, '.git'))) return;
138
- const f = join(r, '.git', 'info', 'exclude');
175
+ // the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
176
+ const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
177
+ if (!rel) return;
178
+ const f = resolve(r, rel);
139
179
  // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
140
180
  // the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
141
181
  const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n# <<< sawbuck <<<\n';
package/sawbuck.mjs CHANGED
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
19
19
  import { homedir } from 'node:os';
20
20
  import { recall as memRecall, conform as memConform } from './memory.mjs';
21
21
  import { PW } from './bins.mjs';
22
- import { frameBlock, mandatesInForce, protectedDirty } from './contract.mjs';
22
+ import { frameBlock, mandatesInForce, protectedDirty, blobAt } from './contract.mjs';
23
23
 
24
24
  const SELF = fileURLToPath(import.meta.url);
25
25
  const HERE = dirname(SELF);
@@ -101,6 +101,15 @@ Usage:
101
101
  Engines: promptwheel (the check) + @promptwheel/logbook (memory). Zero-LLM, offline, free.`;
102
102
 
103
103
  const die = (msg) => { process.stderr.write(`sawbuck: ${msg}\n`); process.exit(1); };
104
+ const usage = (msg) => { process.stderr.write(`sawbuck: ${msg}\n`); process.exit(64); }; // could not run: never a verdict code
105
+ // the repository a path belongs to (its toplevel), so a command run from a subdirectory lands at the
106
+ // root: hooks pinned to the root, the receipt and the exclude under its git dir. '' outside a repo.
107
+ function gitTop(p) { try { return execFileSync('git', ['-C', p, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } }
108
+ const repoRoot = () => gitTop(repoArg()) || repoArg();
109
+ // paths inside the repository's git dir, asked of git: a worktree or a submodule keeps its git dir
110
+ // elsewhere (.git is a FILE there), so join(repo, '.git', ...) is wrong in exactly the repos where it hurts
111
+ function gitPath(repo, rel) { try { return resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', rel], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return join(repo, '.git', rel); } }
112
+ function gitDir(repo) { try { return resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-dir'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return join(repo, '.git'); } }
104
113
  const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
105
114
 
106
115
  if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
@@ -191,12 +200,12 @@ switch (cmd) {
191
200
  catch (e) { process.exit(e.status || 1); }
192
201
  break;
193
202
  }
194
- case 'context': { const o = contextBlock(repoArg(), flags.has('--speak')); if (o) out(o); break; }
203
+ case 'context': { const o = contextBlock(repoRoot(), flags.has('--speak')); if (o) out(o); break; }
195
204
  case 'frame': {
196
205
  // the prompt face of the contract: re-impress the hard rules (mandates, resolved through `extends`)
197
206
  // as one compact block at the live end of the context, so a deep or freshly compacted session
198
207
  // cannot forget them. Silent without a contract; --speak explains instead.
199
- const o = frameBlock(repoArg());
208
+ const o = frameBlock(repoRoot());
200
209
  if (o) out(o);
201
210
  else if (flags.has('--speak')) out('🐴 sawbuck frame: no mandates in promptwheel.config.json yet. Add one (a protected path) and this re-asserts it every turn and after every compaction, so a deep context cannot forget it.');
202
211
  break;
@@ -205,7 +214,10 @@ switch (cmd) {
205
214
  // the ONE per-prompt hook: the contract frame (forward: what must hold) and the relevant history
206
215
  // (backward: what broke here before) in a single block, one process per prompt. Silent when
207
216
  // there is nothing to say. Wired by `attach --claude` on UserPromptSubmit and after a compaction.
208
- const repo = repoArg();
217
+ // the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
218
+ // subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
219
+ const top = gitTop(repoArg());
220
+ const repo = top || repoArg();
209
221
  // When a PROMPT begins (the UserPromptSubmit hook, never the SessionStart[compact] one, which
210
222
  // fires mid-turn), remember which protected paths were already dirty and what they held, so the
211
223
  // Stop hook's wall counts only what this turn changes and leaves a person's own edits alone.
@@ -214,28 +226,41 @@ switch (cmd) {
214
226
  // taken only when the previous turn actually reached a Stop (an interrupt, a crash, or a re-run of
215
227
  // this command mid-turn leaves the last one standing, so nothing the agent left behind is adopted);
216
228
  // a file still carrying the content the last violation flagged is never baselined; and the
217
- // baseline's commit does not advance past a flagged commit until a clean stop.
229
+ // baseline's commit does not advance past a flagged commit until a clean stop or a commit the
230
+ // person made between turns that no longer carries the flagged content.
218
231
  let hookEvent = '';
219
232
  if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
220
- if (hookEvent === 'UserPromptSubmit') {
233
+ if (hookEvent === 'UserPromptSubmit' && top) {
221
234
  try {
235
+ const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
236
+ let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
237
+ const now = new Date().toISOString();
222
238
  if (mandatesInForce(repo).length) {
223
- const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
224
- let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
225
- const now = new Date().toISOString();
226
239
  const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
227
240
  const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
228
241
  const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
229
242
  if (!unfinished) {
230
243
  const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
231
- const files = protectedDirty(repo);
232
- for (const f of Object.keys(files)) if (flagged[f] === files[f]) delete files[f]; // the agent's un-reverted edit stays on the wall
244
+ const before = (st.lastViolation && st.lastViolation.before && typeof st.lastViolation.before === 'object') ? st.lastViolation.before : {};
245
+ // the content is stored (hash-object -w), so a person's edit the agent later overwrites can
246
+ // be restored by hash; a file still carrying the flagged content is baselined at the content
247
+ // the person had before the agent touched it, or not at all
248
+ const files = protectedDirty(repo, { store: true });
249
+ for (const f of Object.keys(files)) if (flagged[f] === files[f]) { if (before[f] && before[f] !== 'deleted') files[f] = before[f]; else delete files[f]; }
233
250
  let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
234
- st.turnBaseline = { at: now, sha: (st.lastViolation && st.lastViolation.sha) || sha, files };
251
+ // HEAD moved since the last Stop: the agent was not running, so the commits between turns
252
+ // are the person's (accepting a change, a pull, a branch switch) and the baseline starts
253
+ // there, UNLESS HEAD still carries a flagged file's content: then the pin holds (a commit
254
+ // a background process landed after the Stop does not walk the agent's change through).
255
+ const moved = !!(sha && st.lastStopHead && sha !== st.lastStopHead);
256
+ const stillInHead = moved && Object.entries(flagged).some(([f, h]) => { const b = blobAt(repo, sha, f); return h === 'deleted' ? b === '' : (!!h && b === h); });
257
+ const pin = !!(st.lastViolation && st.lastViolation.sha);
258
+ if (moved && !stillInHead && st.lastViolation) delete st.lastViolation.sha;
259
+ st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files };
235
260
  }
236
- st.lastPromptAt = now;
237
- mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
238
261
  }
262
+ st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
263
+ mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
239
264
  } catch { /* the frame never fails a prompt */ }
240
265
  }
241
266
  const o = [frameBlock(repo), contextBlock(repo, false)].filter(Boolean).join('\n');
@@ -245,8 +270,8 @@ switch (cmd) {
245
270
  case 'recall': {
246
271
  // the notes face: supplementary relevant notes for your current changes, from any non-git memory
247
272
  // provider (e.g. an Obsidian vault). On-demand. Git history + live debt come from context / debt.
248
- const repo = repoArg();
249
- const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
273
+ const repo = repoRoot();
274
+ const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
250
275
  let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
251
276
  if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
252
277
  if (!changed.length) { out('🐴 sawbuck: nothing changed to look up.'); break; }
@@ -273,8 +298,11 @@ switch (cmd) {
273
298
  break;
274
299
  }
275
300
  case 'attach': {
276
- const repo = repoArg();
277
- if (flags.has('--user')) { wireClaude(repo, { user: true }); if (!dry) verifyAttach(repo); break; }
301
+ const user = flags.has('--user');
302
+ const top = gitTop(repoArg());
303
+ if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
304
+ const repo = top || repoArg();
305
+ if (user) { wireClaude(repo, { user: true }); if (!dry) verifyAttach(repo); break; }
278
306
  if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); if (!dry) verifyAttach(repo); break; }
279
307
  wireGit(repo);
280
308
  if (!dry) verifyAttach(repo);
@@ -283,7 +311,7 @@ switch (cmd) {
283
311
  case 'detach': {
284
312
  const purge = flags.has('--purge');
285
313
  if (flags.has('--all')) { detachAll(purge); break; }
286
- const repo = repoArg();
314
+ const repo = repoRoot();
287
315
  if (flags.has('--user')) unwireClaude(repo, { user: true });
288
316
  else if (flags.has('--claude')) unwireClaude(repo, {});
289
317
  else {
@@ -298,8 +326,8 @@ switch (cmd) {
298
326
  if (flags.has('--verify')) verifyClean(repo);
299
327
  break;
300
328
  }
301
- case 'trace': { traceRepo(repoArg()); break; }
302
- case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoArg()); break; }
329
+ case 'trace': { traceRepo(repoRoot()); break; }
330
+ case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
303
331
  case 'where': { whereAttached(); break; }
304
332
  case 'statusline': {
305
333
  // compact status-line segment for the cwd repo. Never throws; fast.
@@ -362,7 +390,8 @@ function contextBlock(repo, speak) {
362
390
  // run the gate on the last commit and say what it can or cannot measure, so you never attach
363
391
  // into silent nothing. Best-effort and bounded; the wire already succeeded regardless.
364
392
  function verifyAttach(repo) {
365
- const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
393
+ // stderr ignored: a benign failing probe (rev-parse HEAD~1 on a one-commit repo) must not leak a "fatal:" line
394
+ const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
366
395
  if (!g('rev-parse', 'HEAD')) { out(' attached. Make a commit, then run `sawbuck check` to see it work.'); return; }
367
396
  out(' verifying I can measure here (one-time)...');
368
397
  const base = g('rev-parse', '--verify', 'HEAD~1') ? 'HEAD~1' : 'HEAD';
@@ -373,7 +402,7 @@ function verifyAttach(repo) {
373
402
  }
374
403
 
375
404
  // --- the attach receipt (out of the worktree, inside .git) ------------------
376
- function manifestPath(repo) { return join(repo, '.git', 'sawbuck', 'attach.json'); }
405
+ function manifestPath(repo) { return join(gitDir(repo), 'sawbuck', 'attach.json'); }
377
406
  function userManifestPath() { return join(homedir(), '.sawbuck', 'user-attach.json'); }
378
407
  function readManifest(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { version: 1, attachments: {} }; } }
379
408
  function writeManifest(p, m) {
@@ -388,7 +417,7 @@ function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length ===
388
417
  // --- .git/info/exclude: keep the worktree clean without touching .gitignore --
389
418
  // Rewrites a single marked block to the union of excludes the current
390
419
  // attachments need. Never deletes the file (an empty one is git's own default).
391
- function excludeFile(repo) { return join(repo, '.git', 'info', 'exclude'); }
420
+ function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
392
421
  function syncExclude(repo, manifest) {
393
422
  const need = new Set();
394
423
  const a = manifest.attachments || {};
@@ -418,8 +447,8 @@ function hookBlock(repo) {
418
447
  return `${S_START}\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_COMMON_DIR\nif command -v sawbuck >/dev/null 2>&1; then sawbuck __hook --repo ${r}; else node ${JSON.stringify(SELF)} __hook --repo ${r}; fi || true\n${S_END}\n`;
419
448
  }
420
449
  function wireGit(repo) {
421
- if (!existsSync(join(repo, '.git'))) die(`${repo} is not a git repo (no .git)`);
422
- const pc = join(repo, '.git', 'hooks', 'post-commit');
450
+ if (!gitTop(repo)) usage(`${repo} is not a git repository`);
451
+ const pc = gitPath(repo, 'hooks/post-commit');
423
452
  const existed = existsSync(pc);
424
453
  let body = existed ? readFileSync(pc, 'utf8') : '#!/bin/sh\n';
425
454
  if (body.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
@@ -443,7 +472,7 @@ function wireGit(repo) {
443
472
  out(`sawbuck: attached (git post-commit) in ${repo}`);
444
473
  }
445
474
  function unwireGit(repo) {
446
- const pc = join(repo, '.git', 'hooks', 'post-commit');
475
+ const pc = gitPath(repo, 'hooks/post-commit');
447
476
  const m = readManifest(manifestPath(repo));
448
477
  const hasReceipt = !!m.attachments?.git;
449
478
  if (!existsSync(pc) && !hasReceipt) { out('sawbuck: nothing to detach'); return; }
@@ -560,7 +589,7 @@ function isTrivialSettings(cfg) { return Object.keys(cfg).length === 0; }
560
589
  // --- trace / verify / doctor / purge: show, prove, and clean the footprint --
561
590
  function footprint(repo) {
562
591
  const items = [];
563
- const pc = join(repo, '.git', 'hooks', 'post-commit');
592
+ const pc = gitPath(repo, 'hooks/post-commit');
564
593
  try { if (existsSync(pc) && readFileSync(pc, 'utf8').includes(S_START)) items.push({ what: pc, state: 'git hook' }); } catch {}
565
594
  for (const f of [join(repo, '.claude', 'settings.local.json'), join(repo, '.claude', 'settings.json'), join(homedir(), '.claude', 'settings.json')]) {
566
595
  try { if (existsSync(f) && readFileSync(f, 'utf8').includes('sawbuck.mjs')) items.push({ what: f, state: 'claude hooks' }); } catch {}
@@ -601,7 +630,7 @@ function onPath() {
601
630
  function extractBaked(text) { const m = text.match(/node "([^"]+)"/); return m ? m[1] : null; }
602
631
  function doctorRepo(repo) {
603
632
  const checks = [];
604
- const pc = join(repo, '.git', 'hooks', 'post-commit');
633
+ const pc = gitPath(repo, 'hooks/post-commit');
605
634
  try { if (existsSync(pc)) { const b = readFileSync(pc, 'utf8'); if (b.includes(S_START)) checks.push({ mode: 'git', file: pc, baked: extractBaked(b) }); } } catch {}
606
635
  for (const [mode, f] of [['claude', join(repo, '.claude', 'settings.local.json')], ['claude-shared', join(repo, '.claude', 'settings.json')]]) {
607
636
  try { if (existsSync(f)) { const b = readFileSync(f, 'utf8'); if (b.includes('sawbuck.mjs')) checks.push({ mode, file: f, baked: extractBaked(b) }); } } catch {}
package/status.mjs CHANGED
@@ -35,6 +35,10 @@ const seen = feed.length;
35
35
  const catchesAllTime = (results.regressions || 0) + (results.gamed || 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
+ // the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file; the
39
+ // recent-window average over roughly the last twenty scored turns, as the band and statusline show it)
40
+ const steerRec = readJSON(join(repo, '.sawbuck', 'steer.json'));
41
+ const steer = steerRec && steerRec.scored ? { rate: Math.round(((steerRec.ema != null ? steerRec.ema : steerRec.steeringRate) || 0) * 100), scored: steerRec.scored } : null;
38
42
 
39
43
  // --- 2) the armed state: what it can measure here (mirror of statusline) ---
40
44
  const isGit = !!existsSync(join(repo, '.git'));
@@ -65,6 +69,7 @@ if (asJson) {
65
69
  git: isGit,
66
70
  config: hasConfig,
67
71
  outcomes: { seen, caughtAllTime: catchesAllTime, gamedAllTime: results.gamed || 0, regressionsAllTime: results.regressions || 0, recent: tally, latest, since },
72
+ steer,
68
73
  debt: debt ? { total: debt.total, testSkips: debt.testSkips, undocumented: debt.undocumented, filesOnFragileCode: debt.filesOnFragileCode, top: (debt.files || []).slice(0, 5) } : null,
69
74
  debtError: debtErr,
70
75
  }, null, 2));
@@ -105,6 +110,12 @@ if (seen || catchesAllTime) {
105
110
  out.push(` Caught for you · nothing recorded yet (the check logs here on each commit/turn)`);
106
111
  }
107
112
 
113
+ // Steering (only when the judge has scored turns here)
114
+ if (steer) {
115
+ out.push('');
116
+ out.push(` Steer rate · ${steer.rate}% of your recent turns redirected the agent (${steer.scored} scored, recent-window average)`);
117
+ }
118
+
108
119
  // Debt
109
120
  out.push('');
110
121
  if (debt && debt.total) {