sawbuck 0.1.30 → 0.1.31

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
 
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.31",
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
  }
@@ -52,6 +53,20 @@ 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
55
  appendActivity(verdict, report); // the event feed logs EVERY measurable check, so you can watch it work (verified too, not only catches)
56
+ // the results record, and the base this run measured from (a full sha), so a gamed verdict can be
57
+ // held against the SAME span until it re-proves: the hook re-runs base..HEAD, not last-commit..HEAD
58
+ const recDir = join(repo, '.sawbuck');
59
+ const recPath = join(recDir, 'results.json');
60
+ let rec = { regressions: 0, gamed: 0, latest: null };
61
+ try { if (existsSync(recPath)) rec = JSON.parse(readFileSync(recPath, 'utf8')); } catch {}
62
+ let baseSha = ''; try { baseSha = git('rev-parse', `${base}^{commit}`); } catch {}
63
+ const standing = !!(rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.base === baseSha);
64
+ const writeRec = () => { try { mkdirSync(recDir, { recursive: true }); writeFileSync(recPath, JSON.stringify(rec, null, 2) + '\n'); } catch {} };
65
+ if (verdict === 'pass' && standing) {
66
+ // the span the gamed verdict was measured over now passes from the source: the wall comes down
67
+ rec.latest = { sha: git('rev-parse', head).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 };
68
+ writeRec();
69
+ }
55
70
  if (verdict !== 'gamed' && verdict !== 'fail') process.exit(0); // only breakage is worth recording
56
71
 
57
72
  // map the engine verdict to Sawbuck's plain outcome words
@@ -73,17 +88,15 @@ const reason = kind === 'gamed'
73
88
  // --- 3) record the finding in memory (unreviewed, machine-authored note) ----
74
89
  // The Memory Contract's `record` capability, through the adapter (no direct engine shell here).
75
90
  // 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' });
91
+ // and true coupling (the fix diff -> "change X also needs Y"). A gamed verdict still standing
92
+ // over the same span is the same catch, not a new one: no second note, no second tick.
93
+ const repeatOfStanding = standing && kind === 'gamed';
94
+ if (!repeatOfStanding) memRecord(repo, { sha, reason: reason.slice(0, 280), side: 'diff', evidenceFile, by: 'sawbuck' });
78
95
 
79
96
  // --- 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 {}
97
+ if (!repeatOfStanding) { if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1; }
98
+ 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 };
99
+ writeRec();
87
100
 
88
101
  // --- 5) quiet by default (dispatch surfaces on cadence); one line to stderr -
89
102
  process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
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,23 @@ 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
+ let rec = readRec();
38
50
  if (head && st.lastSha && head !== st.lastSha) {
39
- node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', st.lastSha, '--head', head, ...(by ? ['--by', by] : [])]);
51
+ const standing = !!(rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.sha !== head.slice(0, 8));
52
+ node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', standing ? rec.latest.base : st.lastSha, '--head', head, ...(by ? ['--by', by] : []), ...(userMode ? ['--no-auto'] : [])]);
53
+ rec = readRec();
40
54
  }
41
55
  st.lastSha = head || st.lastSha;
42
56
  st.turns += 1;
43
57
 
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 {}
58
+ // 2) the record of what the check has charted (read above, after any re-run)
47
59
  const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
48
60
  const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
49
61
 
@@ -67,17 +79,22 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
67
79
  const leftAlone = dirty.filter(untouched);
68
80
  const hits = changed.length ? mandateHits(repo, changed) : [];
69
81
  st.lastStopAt = now; // the next prompt may take a fresh baseline only because this stop happened
82
+ st.lastStopHead = head || null; // and where HEAD stood: a commit between turns is the person's, not the agent's
70
83
  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);
84
+ // remember EVERY protected path THIS TURN changed (dirty past the baseline, or committed since
85
+ // it) and what it holds, uncapped, plus the commit the baseline stood at, so the next prompt
86
+ // cannot adopt any of it. Only the turn's own changes: a person's file the baseline spared stays
87
+ // spared, or the next clean turn would be blocked over work that was never the agent's.
88
+ const files = {};
89
+ for (const f of protectedAmong(repo, changed)) files[f] = hashOf(repo, f);
75
90
  st.lastViolation = { at: now, sha: (base && base.sha) || null, files };
76
91
  } else if (st.lastViolation) { delete st.lastViolation; }
77
92
  saveState();
78
93
  if (hits.length) {
79
94
  const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
80
- const promptId = hookInput.prompt_id || 'none';
95
+ // the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp
96
+ // (written by `sawbuck turn --hook`), never a constant that would spend the budget for good
97
+ const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
81
98
  if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
82
99
  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
100
  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(', ')}.` : '';
@@ -102,7 +119,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
102
119
  const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
103
120
  if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
104
121
  const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
105
- const promptId = hookInput.prompt_id || 'none';
122
+ const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
106
123
  if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
107
124
  st.shownTotal = total;
108
125
  const where = rec.latest.file;
@@ -134,8 +151,10 @@ saveState();
134
151
  // per-repo `detach` removes them.
135
152
  function ensureExcluded(r) {
136
153
  try {
137
- if (!existsSync(join(r, '.git'))) return;
138
- const f = join(r, '.git', 'info', 'exclude');
154
+ // the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
155
+ const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
156
+ if (!rel) return;
157
+ const f = resolve(r, rel);
139
158
  // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
140
159
  // the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
141
160
  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
@@ -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); }
@@ -214,7 +223,8 @@ switch (cmd) {
214
223
  // taken only when the previous turn actually reached a Stop (an interrupt, a crash, or a re-run of
215
224
  // this command mid-turn leaves the last one standing, so nothing the agent left behind is adopted);
216
225
  // 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.
226
+ // baseline's commit does not advance past a flagged commit until a clean stop or a commit the
227
+ // person made between turns.
218
228
  let hookEvent = '';
219
229
  if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
220
230
  if (hookEvent === 'UserPromptSubmit') {
@@ -231,7 +241,12 @@ switch (cmd) {
231
241
  const files = protectedDirty(repo);
232
242
  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
233
243
  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 };
244
+ // HEAD moved since the last Stop: the agent was not running, so the commits between turns
245
+ // are the person's (accepting a change, a pull, a branch switch) and the baseline starts
246
+ // there. Otherwise the baseline's commit does not advance past a flagged commit.
247
+ const moved = !!(sha && st.lastStopHead && sha !== st.lastStopHead);
248
+ if (moved && st.lastViolation) delete st.lastViolation.sha;
249
+ st.turnBaseline = { at: now, sha: (!moved && st.lastViolation && st.lastViolation.sha) || sha, files };
235
250
  }
236
251
  st.lastPromptAt = now;
237
252
  mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
@@ -273,8 +288,11 @@ switch (cmd) {
273
288
  break;
274
289
  }
275
290
  case 'attach': {
276
- const repo = repoArg();
277
- if (flags.has('--user')) { wireClaude(repo, { user: true }); if (!dry) verifyAttach(repo); break; }
291
+ const user = flags.has('--user');
292
+ const top = gitTop(repoArg());
293
+ if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
294
+ const repo = top || repoArg();
295
+ if (user) { wireClaude(repo, { user: true }); if (!dry) verifyAttach(repo); break; }
278
296
  if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); if (!dry) verifyAttach(repo); break; }
279
297
  wireGit(repo);
280
298
  if (!dry) verifyAttach(repo);
@@ -283,7 +301,7 @@ switch (cmd) {
283
301
  case 'detach': {
284
302
  const purge = flags.has('--purge');
285
303
  if (flags.has('--all')) { detachAll(purge); break; }
286
- const repo = repoArg();
304
+ const repo = repoRoot();
287
305
  if (flags.has('--user')) unwireClaude(repo, { user: true });
288
306
  else if (flags.has('--claude')) unwireClaude(repo, {});
289
307
  else {
@@ -298,8 +316,8 @@ switch (cmd) {
298
316
  if (flags.has('--verify')) verifyClean(repo);
299
317
  break;
300
318
  }
301
- case 'trace': { traceRepo(repoArg()); break; }
302
- case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoArg()); break; }
319
+ case 'trace': { traceRepo(repoRoot()); break; }
320
+ case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
303
321
  case 'where': { whereAttached(); break; }
304
322
  case 'statusline': {
305
323
  // compact status-line segment for the cwd repo. Never throws; fast.
@@ -362,7 +380,8 @@ function contextBlock(repo, speak) {
362
380
  // run the gate on the last commit and say what it can or cannot measure, so you never attach
363
381
  // into silent nothing. Best-effort and bounded; the wire already succeeded regardless.
364
382
  function verifyAttach(repo) {
365
- const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
383
+ // stderr ignored: a benign failing probe (rev-parse HEAD~1 on a one-commit repo) must not leak a "fatal:" line
384
+ const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
366
385
  if (!g('rev-parse', 'HEAD')) { out(' attached. Make a commit, then run `sawbuck check` to see it work.'); return; }
367
386
  out(' verifying I can measure here (one-time)...');
368
387
  const base = g('rev-parse', '--verify', 'HEAD~1') ? 'HEAD~1' : 'HEAD';
@@ -373,7 +392,7 @@ function verifyAttach(repo) {
373
392
  }
374
393
 
375
394
  // --- the attach receipt (out of the worktree, inside .git) ------------------
376
- function manifestPath(repo) { return join(repo, '.git', 'sawbuck', 'attach.json'); }
395
+ function manifestPath(repo) { return join(gitDir(repo), 'sawbuck', 'attach.json'); }
377
396
  function userManifestPath() { return join(homedir(), '.sawbuck', 'user-attach.json'); }
378
397
  function readManifest(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { version: 1, attachments: {} }; } }
379
398
  function writeManifest(p, m) {
@@ -388,7 +407,7 @@ function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length ===
388
407
  // --- .git/info/exclude: keep the worktree clean without touching .gitignore --
389
408
  // Rewrites a single marked block to the union of excludes the current
390
409
  // attachments need. Never deletes the file (an empty one is git's own default).
391
- function excludeFile(repo) { return join(repo, '.git', 'info', 'exclude'); }
410
+ function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
392
411
  function syncExclude(repo, manifest) {
393
412
  const need = new Set();
394
413
  const a = manifest.attachments || {};
@@ -418,8 +437,8 @@ function hookBlock(repo) {
418
437
  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
438
  }
420
439
  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');
440
+ if (!gitTop(repo)) usage(`${repo} is not a git repository`);
441
+ const pc = gitPath(repo, 'hooks/post-commit');
423
442
  const existed = existsSync(pc);
424
443
  let body = existed ? readFileSync(pc, 'utf8') : '#!/bin/sh\n';
425
444
  if (body.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
@@ -443,7 +462,7 @@ function wireGit(repo) {
443
462
  out(`sawbuck: attached (git post-commit) in ${repo}`);
444
463
  }
445
464
  function unwireGit(repo) {
446
- const pc = join(repo, '.git', 'hooks', 'post-commit');
465
+ const pc = gitPath(repo, 'hooks/post-commit');
447
466
  const m = readManifest(manifestPath(repo));
448
467
  const hasReceipt = !!m.attachments?.git;
449
468
  if (!existsSync(pc) && !hasReceipt) { out('sawbuck: nothing to detach'); return; }
@@ -560,7 +579,7 @@ function isTrivialSettings(cfg) { return Object.keys(cfg).length === 0; }
560
579
  // --- trace / verify / doctor / purge: show, prove, and clean the footprint --
561
580
  function footprint(repo) {
562
581
  const items = [];
563
- const pc = join(repo, '.git', 'hooks', 'post-commit');
582
+ const pc = gitPath(repo, 'hooks/post-commit');
564
583
  try { if (existsSync(pc) && readFileSync(pc, 'utf8').includes(S_START)) items.push({ what: pc, state: 'git hook' }); } catch {}
565
584
  for (const f of [join(repo, '.claude', 'settings.local.json'), join(repo, '.claude', 'settings.json'), join(homedir(), '.claude', 'settings.json')]) {
566
585
  try { if (existsSync(f) && readFileSync(f, 'utf8').includes('sawbuck.mjs')) items.push({ what: f, state: 'claude hooks' }); } catch {}
@@ -601,7 +620,7 @@ function onPath() {
601
620
  function extractBaked(text) { const m = text.match(/node "([^"]+)"/); return m ? m[1] : null; }
602
621
  function doctorRepo(repo) {
603
622
  const checks = [];
604
- const pc = join(repo, '.git', 'hooks', 'post-commit');
623
+ const pc = gitPath(repo, 'hooks/post-commit');
605
624
  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
625
  for (const [mode, f] of [['claude', join(repo, '.claude', 'settings.local.json')], ['claude-shared', join(repo, '.claude', 'settings.json')]]) {
607
626
  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) {