sawbuck 0.1.46 → 0.1.48

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/sawbuck.mjs CHANGED
@@ -1,47 +1,66 @@
1
1
  #!/usr/bin/env node
2
2
  // sawbuck, the CLI. Zero-dep.
3
- // `attach` wires the Claude Code hooks into a repo; `detach` removes them and leaves the repo exactly as it was. The
3
+ // `attach` wires the Claude Code hooks into a repo; `detach` removes them, and the record stays until `--purge`. The
4
4
  // repo never imports Sawbuck. Rides two engines: promptwheel (the check) + @promptwheel/logbook.
5
5
  //
6
6
  // attach writes a receipt to .git/sawbuck/attach.json recording every file it created or modified. detach reads it and
7
- // reverses exactly that; `detach --verify` proves the repo came back clean; `doctor` checks the attachment still resolves.
7
+ // reverses exactly that, and still takes our hooks out when the receipt is lost; `detach --verify` proves the wiring is
8
+ // gone and names what is left; `doctor` runs what each hook runs.
8
9
 
10
+ import './pipe.mjs';
9
11
  import { execFileSync } from 'node:child_process';
10
12
  import { erasedBlock, erasedCommits } from './rewrites.mjs';
11
13
  import { steersReport } from './steers.mjs';
12
14
  import { rulesCli } from './rules.mjs';
13
15
  import { shapeCli } from './shape.mjs';
14
16
  import { ensureExcluded, clearExcluded, EXCLUDED, RECORD_DIRS } from './exclude.mjs';
15
- import { repoState } from './repo-state.mjs';
16
- import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
17
- import { basename, join, dirname, resolve } from 'node:path';
17
+ import { repoState, hooksIn, OURS, S_START } from './repo-state.mjs';
18
+ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, rmdirSync, rmSync, readdirSync, statSync } from 'node:fs';
19
+ import { basename, join, dirname, resolve, relative } from 'node:path';
18
20
  import { fileURLToPath } from 'node:url';
19
21
  import { homedir } from 'node:os';
20
22
  import { PW } from './bins.mjs';
21
- import { frameBlock, mandatesInForce, protectedDirty, blobAt, lockState } from './contract.mjs';
23
+ import { frameBlock, mandatesInForce, protectedDirty, protectedAmong, blobAt, lockState } from './contract.mjs';
22
24
 
23
25
  const SELF = fileURLToPath(import.meta.url);
24
26
  const HERE = dirname(SELF);
25
27
  const HOOK = join(HERE, 'sawbuck-hook.mjs');
26
28
  // run the engine and parse its JSON verdict (it prints JSON even when it exits nonzero)
29
+ // A commit message is not a person: an agent can write a Sawbuck-Allow trailer, so no sawbuck path honors one
30
+ // (the Stop hook and the git hook do the same through reflect). `--allow` is the declared override.
27
31
  function gateReport(repo, a) {
28
- let o = '', code = 0;
29
- try { o = execFileSync(process.execPath, [PW, ...a, '--no-record', '--json'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 120000 }); }
30
- catch (e) { o = (e.stdout || '').toString(); code = e.status || 0; }
32
+ let o = '', err = '', code = 0;
33
+ try { o = execFileSync(process.execPath, [PW, ...a, '--no-record', '--json'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PROMPTWHEEL_NO_ALLOW_TRAILER: '1' }, timeout: +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 120000 }); }
34
+ catch (e) { o = (e.stdout || '').toString(); err = e.signal ? 'the check timed out' : (e.stderr || '').toString(); code = e.status || 0; }
31
35
  let rep = null; try { rep = JSON.parse(o.slice(o.indexOf('{'))); } catch {}
32
- return { rep, code };
36
+ // the engine's own reason, without its zero-config notice
37
+ const why = err.split('\n').filter((l) => l.trim() && !/^no promptwheel\.config\.json, auto-gating/.test(l)).join(' ').trim();
38
+ return { rep, code, why };
39
+ }
40
+ // why nothing could be measured, in words that match the case: no test command, tests red on both sides, or a
41
+ // win the source-only re-run could not apply. `sawbuck init` only where there is no config to edit yet.
42
+ function inconclusiveWhy(rep, repo) {
43
+ const ms = (rep && rep.metrics) || [];
44
+ const m = ms.find((x) => x.inert) || ms[0];
45
+ if (ms.some((x) => x.unprovable)) return 'a win here could not be re-proved, because the source-only re-run did not apply. Do not trust this green on its own.';
46
+ const hasCfg = existsSync(join(repo, 'promptwheel.config.json'));
47
+ let cmd = '';
48
+ try { cmd = String((JSON.parse(readFileSync(join(repo, 'promptwheel.config.json'), 'utf8')).metrics || []).find((x) => x && x.name === (m && m.name))?.cmd || ''); } catch {}
49
+ const placeholder = /set your test command/.test(cmd) || (!hasCfg && !detectedTests(repo));
50
+ const where = hasCfg ? 'in promptwheel.config.json' : 'with `sawbuck init`, then in promptwheel.config.json';
51
+ if (placeholder) return `there is no test command here, so nothing was measured. It finds npm, pytest, cargo and go tests on its own. Set yours ${where} (the cmd of "${(m && m.name) || 'tests_pass'}").`;
52
+ return `"${(m && m.name) || 'tests_pass'}" failed before and after this change, so there was no win or loss to measure. If your tests are red, fix them first. If they pass for you, the clean copy the check runs in is missing something: set setup or linkDirs ${where}.`;
53
+ }
54
+ // the test commands the engine finds with no config (promptwheel's detectTestCmd)
55
+ function detectedTests(repo) {
56
+ const has = (f) => existsSync(join(repo, f));
57
+ if (['go.mod', 'Cargo.toml', 'pyproject.toml', 'setup.py', 'pytest.ini'].some(has)) return true;
58
+ try { return !!JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).scripts?.test; } catch { return false; }
33
59
  }
34
60
  // is the metric live (actually ran), or inert (never ran in isolation)?
35
61
  const metricLive = (m) => !!m && !m.inert && m.status !== 'unmeasurable' && m.before != null;
36
- const S_START = '# >>> sawbuck >>>';
37
62
  const S_END = '# <<< sawbuck <<<';
38
- const BLOCK_RE = new RegExp(`\\n?${S_START}[\\s\\S]*?${S_END}\\n?`, 'g');
39
- // the record/state dirs the engines write into the worktree; excluded locally so
40
- // attach never dirties `git status`.
41
- // The record Sawbuck keeps on disk but must never let dirty git status: the engine records
42
- // plus the logbook's root working-set (ledger cache, digests, annotation planes). It is
43
- // memory, not the user's source, a user who wants LOGBOOK.md committed still can explicitly
44
- // (exclude only affects UNtracked files).
63
+ const BLOCK_RE = new RegExp(`${S_START}[\\s\\S]*?${S_END}\\n?`, 'g'); // exactly what attach --git puts in, so taking it out leaves the bytes that were there
45
64
 
46
65
  const argv = process.argv.slice(2);
47
66
  const cmd = argv[0];
@@ -62,24 +81,38 @@ Usage:
62
81
  Verify your latest change right now (working tree if dirty, else the last
63
82
  commit, or the range you name). Zero-config, no attach, no hook. The
64
83
  fastest way to see it work. Every promptwheel run flag passes through.
65
- Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
66
- sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
67
- Customize the rules (promptwheel.config.json), with notes from what your
68
- git history shows actually breaks, plus a short guide in AGENTS.md
69
- (--no-agent-block to skip). Wizard in a terminal, non-interactive in a pipe/CI (or --yes).
70
- sawbuck attach [repo] Check every Claude Code turn in this repo. --user for every repo. --dry-run prints the footprint
71
- sawbuck detach [repo] [--user] [--verify] [--purge]
72
- Remove it and restore the repo (no flag: whatever is attached here); --verify
73
- proves it came back clean; --purge also deletes the record (.sawbuck/)
84
+ Exit 0 pass (verified, held, or allowed) · 1 sent back (a regression or a
85
+ protected path) · 2 gamed · 3 inconclusive · 64 could not run
86
+ (--working with nothing uncommitted is 64 too)
87
+ sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block] [--force]
88
+ Write promptwheel.config.json: your detected tests and the tripwires, with
89
+ notes from what your git history shows actually breaks, plus a short guide in
90
+ AGENTS.md (--no-agent-block to skip). Wizard in a terminal, non-interactive in
91
+ a pipe/CI (or --yes). An existing config needs --force.
92
+ sawbuck attach [repo] [--user | --shared | --git] [--dry-run]
93
+ Watch every Claude Code turn in this repo: protected paths at the end of each
94
+ turn, a source-only re-run on each commit. --user: every repo that has a
95
+ promptwheel.config.json. --shared: a tracked settings file a team commits.
96
+ --git: a post-commit hook, for any other agent or none (commits in a linked
97
+ worktree are not checked). --dry-run prints every file it would write
98
+ sawbuck detach [repo] [--user] [--all] [--verify] [--purge] [--dry-run]
99
+ Remove the hooks (no flag: whatever is wired here; --all: every repo). The
100
+ record stays, kept out of git status; --purge deletes it. --verify proves the
101
+ wiring is gone and names what is left. --dry-run lists what would go
74
102
  sawbuck status [repo] The inventory: caught over time + debt accruing now + whether it is armed. --json
75
103
  --log [-n N] for the recent checks, newest first
76
104
  sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
77
105
  The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
78
- shows only what a branch adds; --fail-on exits nonzero (a CI gate)
106
+ shows only what a branch adds; --fail-on exits 1 when that debt (or, without
107
+ --since, any live debt of that kind) is there (a CI gate)
79
108
  sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
80
- sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck
81
- sawbuck contract Give this repo its rules: protected paths, your tests (--dry-run to preview)
82
- --full adds supply-chain guards, the repo lock and a CI check; --strict makes them block
109
+ sawbuck doctor [repo] Run what each hook runs (project and user level) and say if it still works,
110
+ is incomplete, or is gone. --all lists every repo it is attached to and checks each
111
+ sawbuck contract [--dry-run] [--full] [--strict]
112
+ Give this repo its rules: protected paths, the written rule, and your detected
113
+ tests when there is no config yet (--dry-run to preview). --full adds supply-chain
114
+ guards, the repo lock, and a CI check on a github.com repo with a real test
115
+ command; --strict makes them block
83
116
  sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
84
117
  sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
85
118
  sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
@@ -102,6 +135,11 @@ const repoRoot = () => gitTop(repoArg()) || repoArg();
102
135
  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); } }
103
136
  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
137
  const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
138
+ // `detach --dry-run` walks the same path as the real detach and lists what it would touch, so the two cannot drift.
139
+ // What a dry run "removed" reads as gone for the rest of the walk, so the steps that follow decide as they would for real.
140
+ const planned = { lines: new Map(), gone: new Set(), receipts: new Map(), registry: null };
141
+ const there = (p) => existsSync(p) && !planned.gone.has(p);
142
+ const say = (s) => { if (!dry) out(s); };
105
143
 
106
144
  if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
107
145
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
@@ -109,17 +147,36 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
109
147
 
110
148
  sawbuck contract give this repo its rules: protected paths, your tests
111
149
  sawbuck check verify your latest change right now
150
+ sawbuck attach make it automatic in Claude Code: protected paths each turn, every commit re-proved
112
151
  sawbuck status what it has caught here
113
152
 
114
153
  sawbuck help --all everything else`);
115
154
  process.exit(0);
116
155
  }
117
156
 
157
+ // `sawbuck <command> --help`: that command's lines from the help, and nothing runs. Internal commands get the whole help.
158
+ if (rest.includes('--help') || rest.includes('-h')) {
159
+ const own = []; let cur = null;
160
+ for (const l of help.split('\n')) { const m = l.match(/^ sawbuck (\S+)/); if (m) cur = m[1]; else if (!/^ {20,}\S/.test(l)) cur = null; if (cur === cmd) own.push(l); }
161
+ if (own.length || ['rank', 'frame', 'turn', '__hook'].includes(cmd)) { out(own.length ? `sawbuck ${version}\n\n${own.join('\n')}` : help); process.exit(0); }
162
+ }
163
+ // a flag a command does not know is an error, never a silent no-op. `check` is not here: it passes its flags to the
164
+ // engine, which refuses the ones it does not know. --claude is what older installs pass: taken, not listed.
165
+ const KNOWN = {
166
+ contract: ['--dry-run', '--full', '--strict'],
167
+ init: ['--yes', '-y', '--wizard', '--no-investigate', '--no-agent-block', '--force', '--preset'],
168
+ attach: ['--user', '--shared', '--git', '--dry-run', '--claude'],
169
+ detach: ['--user', '--all', '--verify', '--purge', '--dry-run', '--claude'],
170
+ doctor: ['--all'],
171
+ status: ['--json', '--log', '-n'],
172
+ };
173
+ if (KNOWN[cmd]) { const bad = rest.filter((a) => a.startsWith('-') && !KNOWN[cmd].includes(a)); if (bad.length) usage(`unknown flag ${bad.join(' ')} for ${cmd} (it takes ${KNOWN[cmd].filter((f) => f !== '--claude').join(', ')})`); }
174
+
118
175
  switch (cmd) {
119
176
  case 'contract': {
120
177
  const top = gitTop(repoArg());
121
178
  if (!top) usage(`${repoArg()} is not a git repository (run it inside your project)`);
122
- try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => ['--dry-run', '--full', '--strict'].includes(a))], { stdio: 'inherit' }); }
179
+ try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => KNOWN.contract.includes(a))], { stdio: 'inherit' }); }
123
180
  catch (e) { process.exit(e.status || 1); }
124
181
  break;
125
182
  }
@@ -131,14 +188,14 @@ switch (cmd) {
131
188
  case 'status': {
132
189
  // the inventory: one view unioning what the check has caught over time, what debt is accruing
133
190
  // now, and whether it is armed here. The "show me everything" face. --log is the recent feed.
134
- if (flags.has('--log')) { logReport(repoArg(), rest); break; }
135
- try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoArg(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
191
+ if (flags.has('--log')) { logReport(repoRoot(), rest); break; }
192
+ try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoRoot(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
136
193
  catch (e) { process.exit(e.status || 1); }
137
194
  break;
138
195
  }
139
196
  case 'check': {
140
197
  // one-shot: gate your latest change right now (working tree if dirty, else the last commit),
141
- // zero-config (the engine auto-detects your test command), and say plainly whether the win was
198
+ // zero-config (the engine finds npm, pytest, cargo and go tests), and say plainly whether the win was
142
199
  // earned. No attach, no hook, no record. The try-it-first on-ramp.
143
200
  // `sawbuck check` IS `promptwheel run` with a plain-spoken verdict: every engine flag passes
144
201
  // through (--base/--head/--repeat/--allow/--json/--no-detect-gaming), so there is one entry
@@ -148,7 +205,7 @@ switch (cmd) {
148
205
  for (let i = 0; i < rest.length; i++) {
149
206
  const a = rest[i];
150
207
  if (a === '--json') { json = true; continue; }
151
- if (VALUE_FLAGS.has(a)) { const v = rest[++i]; if (v == null) die(`${a} needs a value`); if (a === '--base') base = v; else if (a === '--head') head = v; else pass.push(a, v); continue; }
208
+ if (VALUE_FLAGS.has(a)) { const v = rest[++i]; if (v == null) usage(`${a} needs a value`); if (a === '--base') base = v; else if (a === '--head') head = v; else pass.push(a, v); continue; }
152
209
  if (a.startsWith('--')) { pass.push(a); continue; }
153
210
  pos.push(a);
154
211
  }
@@ -159,24 +216,40 @@ switch (cmd) {
159
216
  // 64 (EX_USAGE) for every "could not run" below, never a verdict code: a script or CI must not read it as gamed (2).
160
217
  if (!g('rev-parse', '--git-dir')) { out(`🐴 sawbuck: ${repo} is not a git repo. The check re-proves a change against git history, so run it inside one (git init, then commit).`); process.exit(64); }
161
218
  if (!g('rev-parse', '--verify', 'HEAD')) { out('🐴 sawbuck: no commit yet. Make one, then `sawbuck check` verifies your next change against it.'); process.exit(64); }
162
- const dirty = !base && !head && g('status', '--porcelain').length > 0;
219
+ const uncommitted = g('status', '--porcelain').length > 0;
220
+ const dirty = !base && !head && uncommitted;
221
+ // --working on a clean tree would read the tree against the commit it already is, and call that held
222
+ if (pass.includes('--working') && !uncommitted) { out('🐴 sawbuck: nothing uncommitted to check, the working tree is clean. `sawbuck check` verifies the last commit.'); process.exit(64); }
163
223
  // nothing to verify yet: a clean tree with no previous commit to diff against (a fresh repo). Say so
164
224
  // plainly instead of the generic "could not run" below, so the first run is not a dead end.
165
225
  if (!dirty && !base && !g('rev-parse', '--verify', 'HEAD~1')) { out('🐴 sawbuck: nothing to verify yet, the working tree is clean and this is the only commit. Make a change (or add one more commit), then `sawbuck check` re-proves the win from your source edits alone.'); process.exit(64); }
166
226
  const where = dirty ? 'working' : (base || head) ? `${base || 'HEAD~1'}..${head || 'HEAD'}` : 'last';
167
227
  const args = dirty ? ['run', '--working'] : ['run', '--base', base || 'HEAD~1', '--head', head || 'HEAD'];
168
- const { rep, code } = gateReport(repo, [...args, ...pass]);
169
- if (!rep || !rep.verdict) { out('🐴 sawbuck: could not run the check here. Make sure this is a git repo with a commit, and a test command (or run `sawbuck init`).'); process.exit(64); }
228
+ const { rep, code, why } = gateReport(repo, [...args, ...pass]);
229
+ if (!rep || !rep.verdict) { out(`🐴 sawbuck: could not run the check here: ${why || 'the engine gave no verdict'}`); process.exit(64); }
170
230
  if (json) { out(JSON.stringify(rep)); process.exit(code); }
171
231
  const m = (rep.metrics || [])[0];
172
232
  for (const md of rep.mandates || []) out(`🐴 ⛔ protected path "${md.name}" touched${md.reason ? ': ' + md.reason : ''} (touched: ${(md.files || []).join(', ')})${md.removed ? ' [deleted in this same change; the rule stays in force for the change that removes it]' : ''}`);
173
233
  const win = (rep.metrics || []).find((x) => x.status === 'improved' && !x.tripwire);
174
- 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).`);
234
+ const noDetect = pass.includes('--no-detect-gaming');
235
+ if (rep.verdict === 'pass' && rep.allowed) out(`🐴 ✎ allowed with --allow: "${rep.allowed.reason}". That is a person's call, not a proof: ${(rep.allowed.cleared || []).join(', ') || 'the change'} was let through without a source-only re-proof.`);
236
+ else if (rep.verdict === 'pass' && win && noDetect) out(`🐴 ✓ passed: "${win.name}" improved, but gaming detection is off (--no-detect-gaming), so the win was not re-proved from the source.`);
237
+ else 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).`);
175
238
  else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
176
- 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.');
177
- else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a rule in the contract was broken, however green the rest is.');
239
+ else if (rep.verdict === 'gamed') {
240
+ out('🐴 🚩 gamed: a win here came from editing the test, config, or grader, not the source. It does NOT survive a source-only re-run, so it does not count.');
241
+ if (!dirty && /^(Sawbuck|Promptwheel)-Allow:/im.test(g('log', '--format=%B', `${base || 'HEAD~1'}..${head || 'HEAD'}`))) out('🐴 note: a Sawbuck-Allow trailer does not clear this, because an agent can write a commit message. A person passes --allow "<reason>".');
242
+ }
243
+ else if (rep.verdict === 'fail' && (rep.mandates || []).length) {
244
+ out('🐴 ✗ sent back: a protected path changed, however green the rest is.');
245
+ // the way through, for a person who meant it: two of the default rules take a reason, the rest are lifted first
246
+ const names = (l) => l.map((x) => `"${x.name}"`).join(', ');
247
+ const soft = rep.mandates.filter((x) => x.allowable), hard = rep.mandates.filter((x) => !x.allowable);
248
+ if (soft.length) out(`🐴 if a person meant this: sawbuck check --allow "<reason>" accepts ${names(soft)}, and the result shows the reason.`);
249
+ if (hard.length) out(`🐴 ${names(hard)} ${hard.length > 1 ? 'take' : 'takes'} no --allow. To change that path on purpose, remove that rule from promptwheel.config.json in a commit of its own (that edit takes --allow), then make the change.`);
250
+ }
178
251
  else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
179
- 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.');
252
+ else out(`🐴 ≈ inconclusive: ${inconclusiveWhy(rep, repo)}`);
180
253
  if ((rep.metrics || []).some((x) => x.heldByTestEdit)) out('🐴 ⚠ your tests changed too, and the old tests fail on this source. Fine for an intended change. Otherwise check the tests were not weakened: a green that stays green is not proved.');
181
254
  for (const x of rep.explained || []) out(`🐴 ${x.noted ? 'note' : 'needs a look'}: ${x.what}${x.items ? ': ' + x.items.join(', ') : ''}${!x.noted && x.allowable ? '. If you meant this: --allow "<reason>", or on a pull request comment sawbuck-allow: <reason>' : ''}`);
182
255
  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.`);
@@ -233,7 +306,9 @@ switch (cmd) {
233
306
  { // a baseline even with no rules yet: a turn that signs a contract is judged by the contract it began with
234
307
  const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
235
308
  const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
236
- const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
309
+ // a baseline attach wrote is not a prompt: it covers the turn that ran attach, if there was one. Set up from
310
+ // a terminal, nothing is in flight, and the commits made before this prompt are the person's.
311
+ const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince && prev.by !== 'attach';
237
312
  if (!unfinished) {
238
313
  const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
239
314
  const before = (st.lastViolation && st.lastViolation.before && typeof st.lastViolation.before === 'object') ? st.lastViolation.before : {};
@@ -272,32 +347,41 @@ switch (cmd) {
272
347
  break;
273
348
  }
274
349
  case 'attach': {
275
- const user = flags.has('--user');
350
+ const user = flags.has('--user'), shared = flags.has('--shared'), git = flags.has('--git');
351
+ if (user + shared + git > 1) usage('attach takes one of --user, --shared or --git');
276
352
  const top = gitTop(repoArg());
277
353
  if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
278
354
  const repo = top || repoArg();
279
- wireClaude(repo, { user });
355
+ if (git) wireGit(repo); else wireClaude(repo, { user, shared });
280
356
  if (!dry) verifyAttach(repo);
281
357
  break;
282
358
  }
283
359
  case 'detach': {
284
360
  const purge = flags.has('--purge');
285
- const repo = repoRoot();
286
- if (flags.has('--user')) unwireClaude(repo, { user: true });
287
- else if (flags.has('--claude')) unwireClaude(repo, {});
361
+ if (flags.has('--all')) detachAll(purge);
288
362
  else {
289
- // no flag: reverse whatever this repo's receipt says is attached, older git hooks included,
290
- // so a bare `detach` is always the inverse of the `attach` you ran.
291
- const a = readManifest(manifestPath(repo)).attachments || {};
292
- const hasClaude = !!(a.claude || a['claude-shared']);
293
- if (hasClaude) unwireClaude(repo, {});
294
- if (a.git || !hasClaude) unwireGit(repo);
363
+ const repo = repoRoot();
364
+ if (flags.has('--user')) unwireClaude(repo, { user: true });
365
+ else if (flags.has('--claude')) unwireClaude(repo, {});
366
+ else {
367
+ // no flag: take out whatever is wired in this repo, older git hooks included. The receipt says what attach
368
+ // wrote; our hooks in the project settings or the post-commit hook count without one, so a lost receipt or a
369
+ // teammate's clone of a --shared repo is not left wired.
370
+ const a = readManifest(manifestPath(repo)).attachments || {};
371
+ const hasClaude = !!(a.claude || a['claude-shared']) || [false, true].some((shared) => hooksIn(claudeFile(repo, { shared }).f));
372
+ if (hasClaude) unwireClaude(repo, {});
373
+ if (a.git || hasBlock(repo) || !hasClaude) unwireGit(repo);
374
+ }
375
+ if (purge) purgeRecord(repo);
376
+ syncExclude(repo, readManifest(manifestPath(repo))); // after a purge the exclude lines go too
377
+ const left = recordLeft(repo);
378
+ if (left.length) say(`sawbuck: still on disk: ${left.join(', ')}. It is the record, kept out of git status; \`sawbuck detach --purge\` removes it.`);
379
+ if (flags.has('--verify') && !dry) verifyClean(repo);
295
380
  }
296
- if (purge) purgeRecord(repo);
297
- if (flags.has('--verify')) verifyClean(repo);
381
+ if (dry) out(dryText([...planned.lines.values()], 'remove'));
298
382
  break;
299
383
  }
300
- case 'doctor': { doctorRepo(repoRoot()); break; }
384
+ case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
301
385
  case 'rules': { out(rulesCli(rest)); break; }
302
386
  case 'shape': { out(shapeCli(rest)); break; }
303
387
  case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
@@ -388,51 +472,139 @@ function verifyAttach(repo) {
388
472
  const { rep } = gateReport(repo, ['run', '--base', base, '--head', 'HEAD']);
389
473
  const m = rep && (rep.metrics || [])[0];
390
474
  if (metricLive(m)) out(` ✓ I can measure "${m.name}" here. On each commit I'll verify a win survived a source-only re-run, and flag it if it did not.`);
391
- else out(' ⚠ I could not measure anything here yet (your test command did not run in isolation). Point me at it: `sawbuck init`, or set setup/linkDirs in promptwheel.config.json. I stay quiet until then.');
475
+ else out(` ⚠ I could not measure anything here yet: ${rep ? inconclusiveWhy(rep, repo) : 'the check did not run'}`);
392
476
  }
393
477
 
394
478
  // --- the attach receipt (out of the worktree, inside .git) ------------------
395
479
  function manifestPath(repo) { return join(gitDir(repo), 'sawbuck', 'attach.json'); }
396
480
  function userManifestPath() { return join(homedir(), '.sawbuck', 'user-attach.json'); }
397
- function readManifest(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { version: 1, attachments: {} }; } }
481
+ function readManifest(p) { if (planned.receipts.has(p)) return planned.receipts.get(p); try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { version: 1, attachments: {} }; } }
398
482
  function writeManifest(p, m) {
399
- if (!Object.keys(m.attachments || {}).length) { delFile(p); rmIfEmptyDir(dirname(p)); return; }
400
- mkdirSync(dirname(p), { recursive: true });
401
- writeFileSync(p, JSON.stringify(m, null, 2) + '\n');
483
+ if (dry) planned.receipts.set(p, m);
484
+ if (!Object.keys(m.attachments || {}).length) { dropFile(p, 'the attach receipt'); rmIfEmptyDir(dirname(p)); return; }
485
+ putFile(p, JSON.stringify(m, null, 2) + '\n', 'the attach receipt');
402
486
  }
403
487
 
404
- function delFile(p) { try { if (existsSync(p)) unlinkSync(p); } catch {} }
405
- function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
406
-
488
+ // Every write and removal on the detach path goes through these two: under --dry-run each is listed instead of done.
489
+ function putFile(f, text, what) {
490
+ if (dry) { planned.lines.set(f, `modify ${f} (${what})`); return; }
491
+ mkdirSync(dirname(f), { recursive: true });
492
+ writeFileSync(f, text);
493
+ }
494
+ function dropFile(p, what) {
495
+ if (!there(p)) return;
496
+ if (dry) { planned.lines.set(p, `delete ${p} (${what})`); planned.gone.add(p); return; }
497
+ try { rmSync(p, { recursive: true, force: true }); } catch {}
498
+ }
499
+ function rmIfEmptyDir(d) { if (dry) return; try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
407
500
  // --- .git/info/exclude: keep the worktree clean without touching .gitignore --
408
501
  // Any attachment keeps the block, the last one out takes it. Returns whether the file changed.
409
502
  function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
410
- function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
503
+ // The lines stay while the record they hide is still on disk: clearing them would put .sawbuck/ in git status and a
504
+ // signed repo would fail its own hooks-frozen rule over Sawbuck's record. --purge deletes the record, then the lines.
505
+ function syncExclude(repo, manifest) {
506
+ const changed = Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo, dry) : hiddenLeft(repo).length ? false : clearExcluded(repo, dry);
507
+ if (dry && changed) planned.lines.set(excludeFile(repo), `modify ${excludeFile(repo)} (the lines that keep the record out of git status)`);
508
+ return changed;
509
+ }
510
+ // the record paths still on disk and not tracked (a committed LOGBOOK.md is the person's file)
511
+ function recordLeft(repo) {
512
+ const tracked = (p) => { try { return !!execFileSync('git', ['-C', repo, 'ls-files', '--', p], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return false; } };
513
+ return ['.sawbuck/', '.promptwheel/outcomes.jsonl', 'events.jsonl', 'annotations.jsonl', 'LOGBOOK.md', 'JOURNEY.md'].filter((p) => there(join(repo, p)) && !tracked(p));
514
+ }
515
+ // what the block hides that is still there: the record, and a settings.local.json the person keeps
516
+ function hiddenLeft(repo) {
517
+ const local = join(repo, '.claude', 'settings.local.json');
518
+ let own = false; try { own = there(local) && !execFileSync('git', ['-C', repo, 'ls-files', '--', '.claude/settings.local.json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
519
+ return [...recordLeft(repo), ...(own ? ['.claude/settings.local.json'] : [])];
520
+ }
411
521
 
412
- // --- git post-commit --------------------------------------------------------
413
- // Older versions could attach here. Nothing wires it now; detach still takes it out.
522
+ // --- git post-commit (attach --git) -----------------------------------------
523
+ // For any agent or none: every commit is checked, whatever made it. The hook prefers a `sawbuck` on PATH (so it
524
+ // survives a reinstall), then the absolute path of the sawbuck that attached, then the same version through npx.
525
+ function hookBlock(repo) {
526
+ const r = JSON.stringify(repo);
527
+ // git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check shells out to git and would
528
+ // inherit them, operate on the wrong repo, and silently catch nothing. Clear them first, in a subshell: the block
529
+ // sits above the person's own hook lines, and those keep what git gave them.
530
+ return `${S_START}\n(unset 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}; elif [ -f ${JSON.stringify(SELF)} ]; then ${nodeCmd()} ${JSON.stringify(SELF)} __hook --repo ${r}; else npx -y sawbuck@${version} __hook --repo ${r}; fi) || true\n${S_END}\n`;
531
+ }
532
+ // The first commit after attach is checked only if there is a commit to check it from: HEAD as it stands now.
533
+ // A baseline other hooks are keeping (Claude hooks wired here or at user level, a turn in flight) is left alone;
534
+ // one left behind by an attachment that is gone is not, or commits made while nothing watched would be laid on the next one.
535
+ function gitBaseline(repo) {
536
+ let sha = ''; try { sha = execFileSync('git', ['-C', repo, 'rev-parse', '--verify', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
537
+ const path = join(repo, '.sawbuck', 'state.json');
538
+ let st = {}; try { st = JSON.parse(readFileSync(path, 'utf8')); } catch {}
539
+ const kept = (st.lastSha || (st.turnBaseline && st.turnBaseline.sha)) && (Object.keys(readManifest(manifestPath(repo)).attachments || {}).length || hooksIn(claudeFile(repo, { user: true }).f));
540
+ return { path, st, sha: kept ? '' : sha };
541
+ }
542
+ function wireGit(repo) {
543
+ const pc = gitPath(repo, 'hooks/post-commit');
544
+ // core.hooksPath inside the work tree (the husky way): the team tracks those files or a hook tool rewrites them, and
545
+ // our block pins this machine's paths. Refuse, and give the portable line a person can add.
546
+ if (!relative(repo, pc).startsWith('..') && relative(gitDir(repo), pc).startsWith('..')) usage(`git hooks here live in ${relative(repo, dirname(pc))}/, inside the work tree (core.hooksPath), where the team or a hook tool owns the files, so attach --git writes nothing. Use plain \`sawbuck attach\`, or add this line by hand to the post-commit hook kept there (with husky: .husky/post-commit): (unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_COMMON_DIR; npx -y sawbuck@${version} __hook --repo "$PWD") || true`);
547
+ const existed = existsSync(pc);
548
+ const was = existed ? readFileSync(pc, 'utf8') : '';
549
+ if (was.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
550
+ // our block is shell, and git only runs an executable hook: a hook in another language, or one git is not running,
551
+ // is left exactly as it is
552
+ if (existed && was.startsWith('#!') && !/^#!\s*\S*\/(env\s+)?(ba|da|z|k)?sh\b/.test(was.split('\n')[0])) usage(`the post-commit hook here is not a shell script (${was.split('\n')[0]}). Add this line to it by hand: npx -y sawbuck@${version} __hook`);
553
+ if (existed && process.platform !== 'win32') { let x = false; try { x = !!(statSync(pc).mode & 0o111); } catch {} if (!x) usage(`the post-commit hook here is not executable, so git does not run it. Make it executable (chmod +x ${relative(repo, pc)}) or remove it, then run attach --git again.`); }
554
+ // Our block goes right after the shebang line, so a hook that ends in `exit 0` still reaches it. The receipt notes
555
+ // the shebang or the newline we had to add, so detach leaves a hook that was here before byte for byte as it was.
556
+ const shebang = !was.startsWith('#!'), nl = !shebang && !was.includes('\n');
557
+ const body = (shebang ? '#!/bin/sh\n' : '') + was + (nl ? '\n' : '');
558
+ const at = body.indexOf('\n') + 1;
559
+ const mp = manifestPath(repo), base = gitBaseline(repo);
560
+ if (dry) {
561
+ out(dryText([
562
+ `${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
563
+ ensureExcluded(repo, true) ? `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})` : null,
564
+ `${existsSync(mp) ? 'modify' : 'create'} ${mp} (attach receipt)`,
565
+ base.sha ? `write ${base.path} (the commit to check from, so the first commit after this is checked)` : null,
566
+ `write ${regPath()} (the list of attached repos, for detach --all)`,
567
+ ].filter(Boolean)));
568
+ return;
569
+ }
570
+ writeFileSync(pc, body.slice(0, at) + hookBlock(repo) + body.slice(at));
571
+ chmodSync(pc, 0o755);
572
+ const m = readManifest(mp);
573
+ m.attachments = { ...m.attachments, git: { pc: { created: !existed, ...(existed && shebang ? { shebang } : {}), ...(nl ? { nl } : {}) } } };
574
+ syncExclude(repo, m);
575
+ writeManifest(mp, m);
576
+ if (base.sha) { try { mkdirSync(dirname(base.path), { recursive: true }); writeFileSync(base.path, JSON.stringify({ turns: 0, shownTotal: 0, ...base.st, lastSha: base.sha }, null, 2) + '\n'); } catch {} }
577
+ regAdd(repo, 'git');
578
+ out(`sawbuck: attached (git post-commit) in ${repo}. Every commit is checked, whatever made it.`);
579
+ }
580
+ function hasBlock(repo) { try { return readFileSync(gitPath(repo, 'hooks/post-commit'), 'utf8').includes(S_START); } catch { return false; } }
414
581
  function unwireGit(repo) {
415
582
  const pc = gitPath(repo, 'hooks/post-commit');
416
583
  const m = readManifest(manifestPath(repo));
417
- const hasReceipt = !!m.attachments?.git;
584
+ const rec = m.attachments?.git;
585
+ const wired = hasBlock(repo);
418
586
  // signing with no attach still hides the record, so a repo with no receipt can have a block to take back
419
- if (!existsSync(pc) && !hasReceipt) { out(syncExclude(repo, m) ? `sawbuck: removed the record lines from ${excludeFile(repo)}` : 'sawbuck: nothing to detach'); return; }
420
- if (existsSync(pc)) {
421
- const stripped = readFileSync(pc, 'utf8').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
422
- const created = m.attachments?.git?.pc?.created;
587
+ if (!wired && !rec) { say(syncExclude(repo, m) ? `sawbuck: removed the record lines from ${excludeFile(repo)}` : 'sawbuck: nothing to detach'); return; }
588
+ if (wired) {
589
+ // take out what attach put in, and nothing else: a hook that was here before is the same bytes again
590
+ let rest = readFileSync(pc, 'utf8').replace(BLOCK_RE, '');
591
+ if (rec?.pc?.shebang && rest.startsWith('#!/bin/sh\n')) rest = rest.slice('#!/bin/sh\n'.length);
592
+ if (rec?.pc?.nl && rest.endsWith('\n')) rest = rest.slice(0, -1);
423
593
  // true inverse: if we created this hook and it is trivial again, remove it
424
- if ((created || !hasReceipt) && isTrivialHook(stripped)) delFile(pc);
425
- else writeFileSync(pc, stripped);
594
+ if ((rec?.pc?.created || !rec) && isTrivialHook(rest)) dropFile(pc, 'the post-commit hook attach created');
595
+ else putFile(pc, rest, 'our block in the post-commit hook');
426
596
  }
427
- if (hasReceipt) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
428
- out(`sawbuck: detached (git) from ${repo}`);
597
+ if (rec) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
598
+ regRemove(repo, 'git');
599
+ say(`sawbuck: detached (git) from ${repo}`);
429
600
  }
430
601
  function isTrivialHook(text) { return text.replace(/^#!.*\n?/, '').trim() === ''; }
431
602
 
432
603
  // --- Claude Code hooks ------------------------------------------------------
433
604
  // user-level hooks (~/.claude) target the repo by cwd; project hooks pin the repo.
434
- // Project default writes the gitignored settings.local.json; --shared writes the
435
- // tracked settings.json on purpose. Safe merge; refuses malformed JSON; idempotent.
605
+ // Project default writes the gitignored settings.local.json. --shared writes the tracked settings.json for a team:
606
+ // its hooks run the pinned version through npx and find the repo from where they run, so the file holds no path from
607
+ // this machine, and removing the hooks shows in git. Safe merge; refuses malformed JSON; idempotent.
436
608
  function claudeFile(repo, { user, shared }) {
437
609
  const dir = join(user ? homedir() : repo, '.claude');
438
610
  const name = user || shared ? 'settings.json' : 'settings.local.json';
@@ -446,12 +618,12 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
446
618
  return `"$(command -v node || echo ${JSON.stringify(execPath).replace(/^"|"$/g, "'")})"`;
447
619
  }
448
620
  function wireClaude(repo, opts) {
449
- const { user } = opts;
621
+ const { user, shared } = opts;
450
622
  // user-level hooks already cover every repo; a second set would run each check twice and spend the
451
623
  // send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
452
- if (!user) {
624
+ if (!user && !shared) {
453
625
  const uf = claudeFile(repo, { user: true }).f;
454
- try { if (readFileSync(uf, 'utf8').includes('sawbuck.mjs')) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add`); return; } } catch {}
626
+ try { if (OURS.test(readFileSync(uf, 'utf8'))) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add${existsSync(join(repo, 'promptwheel.config.json')) ? '' : '. The check runs here once this repo has its rules: sawbuck contract'}`); return; } } catch {}
455
627
  }
456
628
  const { dir, f } = claudeFile(repo, opts);
457
629
  const dirExisted = existsSync(dir);
@@ -461,30 +633,41 @@ function wireClaude(repo, opts) {
461
633
  // run through npx, the installed path lives in a cache that can be cleared, and a missing hook fails quietly and
462
634
  // leaves the wall off: fall back to fetching the same version
463
635
  const run = (args) => {
636
+ if (shared) return `npx -y sawbuck@${version} ${args}`;
464
637
  const direct = `${nodeCmd()} ${JSON.stringify(SELF)} ${args}`;
465
638
  return process.platform !== 'win32' && SELF.includes('/_npx/') ? `if [ -f ${JSON.stringify(SELF)} ]; then ${direct}; else npx -y sawbuck@${version} ${args}; fi` : direct;
466
639
  };
467
- const ctx = run(user ? 'turn --hook' : `turn --hook ${JSON.stringify(repo)}`);
468
- const chk = run(user ? '__hook --by claude --stop' : `__hook --repo ${JSON.stringify(repo)} --by claude --stop`);
640
+ const here = user || shared; // no repo path in the command: the hook works out the repo from where it runs
641
+ const ctx = run(here ? 'turn --hook' : `turn --hook ${JSON.stringify(repo)}`);
642
+ const chk = run(here ? '__hook --by claude --stop' : `__hook --repo ${JSON.stringify(repo)} --by claude --stop`);
469
643
  // attached already? "already attached" only when every hook of THIS version is present; an older
470
644
  // attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
471
645
  let upgrading = false;
472
- if (JSON.stringify(cfg).includes('sawbuck.mjs')) {
646
+ if (OURS.test(JSON.stringify(cfg))) {
473
647
  const has = (event, command, matcher) => ((cfg.hooks || {})[event] || []).some((h) => h && (matcher ? h.matcher === matcher : !h.matcher) && Array.isArray(h.hooks) && h.hooks.some((x) => x && x.command === command));
474
- if (has('UserPromptSubmit', ctx) && has('SessionStart', ctx, 'compact') && has('Stop', chk)) { out(`sawbuck: already attached (${user ? 'user' : 'claude'}) in ${f}`); return; }
648
+ if (has('UserPromptSubmit', ctx) && has('SessionStart', ctx, 'compact') && has('Stop', chk)) { out(`sawbuck: already attached (${user ? 'user' : shared ? 'shared' : 'claude'}) in ${f}`); return; }
475
649
  upgrading = true;
476
650
  }
651
+ const mode = user ? 'user' : shared ? 'claude-shared' : 'claude';
652
+ const mp = user ? userManifestPath() : manifestPath(repo);
653
+ const stPath = join(repo, '.sawbuck', 'state.json');
654
+ let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
655
+ // attach often runs inside a turn (saying yes to the offer), and that turn's Stop hook then has no baseline, so the
656
+ // files signing just wrote would read as the agent's violations. What is here now is the baseline; anything after it counts.
657
+ const baseline = !user && existsSync(join(repo, 'promptwheel.config.json')) && !st.turnBaseline;
477
658
  if (dry) {
478
659
  out(dryText([
479
660
  `${upgrading ? 'update' : fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
480
- user ? null : `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
481
- `create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
661
+ !user && ensureExcluded(repo, true) ? `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})` : null,
662
+ `${existsSync(mp) ? 'modify' : 'create'} ${mp} (attach receipt)`,
663
+ baseline ? `write ${stPath} (the turn baseline, so this turn's own signing is not read as the agent's)` : null,
664
+ `write ${regPath()} (the list of attached repos, for detach --all)`,
482
665
  ].filter(Boolean)));
483
666
  return;
484
667
  }
485
668
  cfg.hooks = cfg.hooks || {};
486
669
  const put = (event, command, matcher) => {
487
- cfg.hooks[event] = (cfg.hooks[event] || []).filter((h) => !JSON.stringify(h).includes('sawbuck.mjs'));
670
+ cfg.hooks[event] = (cfg.hooks[event] || []).filter((h) => !OURS.test(JSON.stringify(h)));
488
671
  cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
489
672
  };
490
673
  put('UserPromptSubmit', ctx);
@@ -494,30 +677,25 @@ function wireClaude(repo, opts) {
494
677
  put('Stop', chk);
495
678
  mkdirSync(dir, { recursive: true });
496
679
  writeFileSync(f, JSON.stringify(cfg, null, 2) + '\n');
497
- // attach often runs inside a turn (saying yes to the offer), and that turn's Stop hook then has no baseline, so the
498
- // files signing just wrote would read as the agent's violations. What is here now is the baseline; anything after it counts.
499
- if (!user && existsSync(join(repo, 'promptwheel.config.json'))) {
680
+ if (baseline) {
500
681
  try {
501
682
  ensureExcluded(repo);
502
- const stPath = join(repo, '.sawbuck', 'state.json');
503
- let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
504
- if (!st.turnBaseline) {
505
- let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
506
- st.turnBaseline = { sha, files: protectedDirty(repo, { store: true }) };
507
- st.lastPromptAt = new Date().toISOString();
508
- mkdirSync(dirname(stPath), { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
509
- }
683
+ let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
684
+ st.turnBaseline = { sha, files: protectedDirty(repo, { store: true }), by: 'attach' };
685
+ st.lastPromptAt = new Date().toISOString();
686
+ mkdirSync(dirname(stPath), { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
510
687
  } catch {}
511
688
  }
512
- const mode = user ? 'user' : 'claude';
513
- const mp = user ? userManifestPath() : manifestPath(repo);
514
689
  const m = readManifest(mp);
515
690
  const prev = (m.attachments || {})[mode]; // an upgrade keeps the receipt of what the first attach created
516
691
  m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: prev ? !!prev.fileCreated : !fileExisted, dirCreated: prev ? !!prev.dirCreated : !dirExisted } };
517
692
  if (!user) syncExclude(repo, m);
518
693
  writeManifest(mp, m);
519
- out(upgrading ? `sawbuck: updated the ${user ? 'USER-LEVEL' : 'project'} Claude Code hooks in ${f} to this version`
520
- : `sawbuck: wired ${user ? 'USER-LEVEL' : 'project'} Claude Code hooks in ${f}`);
694
+ regAdd(repo, mode);
695
+ const kind = user ? 'USER-LEVEL' : shared ? 'shared project' : 'project';
696
+ out(upgrading ? `sawbuck: updated the ${kind} Claude Code hooks in ${f} to this version` : `sawbuck: wired ${kind} Claude Code hooks in ${f}`);
697
+ if (shared) out(` commit ${relative(repo, f)} so the team gets the check. It runs sawbuck ${version} through npx on every machine.`);
698
+ if (shared && protectedAmong(repo, [relative(repo, f)]).length) out(` That file is a protected path in this repo, so the check on its commit reads "sent back" unless a person passes --allow "team hooks" (on a pull request: a comment, sawbuck-allow: team hooks).`);
521
699
  if (!user && !existsSync(join(repo, 'AGENTS.md')) && !existsSync(join(repo, 'CLAUDE.md'))) out(' no written rule here yet, and that is what changes the agent: run sawbuck contract.');
522
700
  if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
523
701
  }
@@ -531,13 +709,13 @@ function unwireClaude(repo, { user }) {
531
709
  const f = rec?.file || claudeFile(repo, { user, shared: mode === 'claude-shared' }).f;
532
710
  if (existsSync(f)) {
533
711
  let raw = ''; try { raw = readFileSync(f, 'utf8'); } catch {}
534
- if (raw.includes('sawbuck.mjs')) {
712
+ if (OURS.test(raw)) {
535
713
  let cfg = null; try { cfg = JSON.parse(raw); } catch {}
536
714
  if (cfg) {
537
715
  removeClaudeEntries(cfg);
538
- if ((rec?.fileCreated || !rec) && isTrivialSettings(cfg)) { delFile(f); if (rec?.dirCreated) rmIfEmptyDir(dirname(f)); }
539
- else writeFileSync(f, JSON.stringify(cfg, null, 2) + '\n');
540
- out(`sawbuck: removed Claude Code hooks from ${f}`);
716
+ if ((rec?.fileCreated || !rec) && isTrivialSettings(cfg)) { dropFile(f, 'our Claude Code hooks were all it held'); if (rec?.dirCreated) rmIfEmptyDir(dirname(f)); }
717
+ else putFile(f, JSON.stringify(cfg, null, 2) + '\n', 'our Claude Code hooks');
718
+ say(`sawbuck: removed Claude Code hooks from ${f}`);
541
719
  did = true;
542
720
  }
543
721
  }
@@ -545,77 +723,120 @@ function unwireClaude(repo, { user }) {
545
723
  if (rec) { delete m.attachments[mode]; touched = true; }
546
724
  }
547
725
  if (touched) { if (!user) syncExclude(repo, m); writeManifest(mp, m); }
548
- if (!did && !touched) out('sawbuck: no Claude attachment found to detach');
726
+ for (const mode of modes) regRemove(repo, mode);
727
+ if (!did && !touched) say('sawbuck: no Claude attachment found to detach');
549
728
  }
550
729
  function removeClaudeEntries(cfg) {
551
730
  if (!cfg.hooks) return;
552
731
  for (const ev of ['UserPromptSubmit', 'SessionStart', 'Stop']) {
553
732
  if (!Array.isArray(cfg.hooks[ev])) continue;
554
- cfg.hooks[ev] = cfg.hooks[ev].filter((h) => !JSON.stringify(h).includes('sawbuck.mjs'));
733
+ cfg.hooks[ev] = cfg.hooks[ev].filter((h) => !OURS.test(JSON.stringify(h)));
555
734
  if (!cfg.hooks[ev].length) delete cfg.hooks[ev]; // no empty [] residue
556
735
  }
557
736
  if (cfg.hooks && !Object.keys(cfg.hooks).length) delete cfg.hooks;
558
737
  }
559
738
  function isTrivialSettings(cfg) { return Object.keys(cfg).length === 0; }
560
739
 
561
- // --- trace / verify / doctor / purge: show, prove, and clean the footprint --
740
+ // --- verify / doctor / purge: prove and clean the footprint ----------------
562
741
  function footprint(repo) {
563
742
  const items = [];
564
743
  const pc = gitPath(repo, 'hooks/post-commit');
565
744
  try { if (existsSync(pc) && readFileSync(pc, 'utf8').includes(S_START)) items.push({ what: pc, state: 'git hook' }); } catch {}
566
745
  for (const f of [join(repo, '.claude', 'settings.local.json'), join(repo, '.claude', 'settings.json'), join(homedir(), '.claude', 'settings.json')]) {
567
- try { if (existsSync(f) && readFileSync(f, 'utf8').includes('sawbuck.mjs')) items.push({ what: f, state: 'claude hooks' }); } catch {}
746
+ try { if (existsSync(f) && OURS.test(readFileSync(f, 'utf8'))) items.push({ what: f, state: 'claude hooks' }); } catch {}
568
747
  }
569
748
  const xf = excludeFile(repo);
570
- try { if (existsSync(xf) && readFileSync(xf, 'utf8').includes(S_START)) items.push({ what: xf, state: 'local exclude' }); } catch {}
571
- for (const d of RECORD_DIRS) if (existsSync(join(repo, d))) items.push({ what: join(repo, d), state: 'record dir (ignored)' });
749
+ // the exclude lines are part of the record while it is on disk (syncExclude), wiring only once it is gone
750
+ try { if (existsSync(xf) && readFileSync(xf, 'utf8').includes(S_START) && !hiddenLeft(repo).length) items.push({ what: xf, state: 'local exclude' }); } catch {}
572
751
  const mp = manifestPath(repo);
573
752
  if (existsSync(mp)) { const m = readManifest(mp); items.push({ what: mp, state: `receipt: ${Object.keys(m.attachments || {}).join(', ') || 'empty'}` }); }
574
753
  return items;
575
754
  }
576
755
  function verifyClean(repo) {
577
- // the record dirs may stay (your data, unless --purge); the mechanism must be gone.
578
- const left = footprint(repo).filter((it) => !/record dir/.test(it.state));
579
- if (!left.length) out('sawbuck: verified, the repo is back to how it was (mechanism fully removed).');
756
+ // the record may stay (your data, unless --purge, and named above); the mechanism must be gone.
757
+ const left = footprint(repo);
758
+ if (!left.length) out('sawbuck: verified, the hooks and the receipt are gone.');
580
759
  else { out('sawbuck: NOT clean, these remain:'); for (const it of left) out(` ${it.state.padEnd(22)} ${it.what}`); process.exitCode = 1; }
581
760
  }
582
761
  function purgeRecord(repo) {
583
762
  let n = 0;
763
+ const purge = (p) => { if (!there(p)) return; dropFile(p, 'the record'); n++; say(`sawbuck: purged ${p}`); };
584
764
  for (const d of RECORD_DIRS) {
585
- const p = join(repo, d);
586
765
  // a committed LOGBOOK.md or JOURNEY.md is the person's file, not our record
587
766
  try { if (execFileSync('git', ['-C', repo, 'ls-files', '--', d], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()) continue; } catch {}
588
- try { if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); n++; out(`sawbuck: purged ${p}`); } } catch {}
767
+ purge(join(repo, d));
589
768
  }
590
- const outcomes = join(repo, '.promptwheel', 'outcomes.jsonl'); // the rest of .promptwheel/ is the signed contract: yours to keep or delete
591
- try { if (existsSync(outcomes)) { rmSync(outcomes, { force: true }); n++; out(`sawbuck: purged ${outcomes}`); } } catch {}
592
- if (!n) out('sawbuck: no record to purge');
593
- }
594
- // is `sawbuck` resolvable on PATH? (SAWBUCK_NO_PATH_CHECK forces the strict/offline answer)
595
- function onPath() {
596
- if (process.env.SAWBUCK_NO_PATH_CHECK) return false;
597
- try { execFileSync('sh', ['-c', 'command -v sawbuck'], { stdio: 'ignore' }); return true; } catch { return false; }
769
+ purge(join(repo, '.promptwheel', 'outcomes.jsonl')); // the rest of .promptwheel/ is the signed contract: yours to keep or delete
770
+ if (!n) say('sawbuck: no record to purge');
598
771
  }
599
772
  // the path a hook bakes in: the quoted one ending in sawbuck.mjs, in a shell hook or in JSON, where the quotes are escaped
600
773
  function extractBaked(text) { const m = text.match(/"([^"]*sawbuck\.mjs)\\?"/); return m ? m[1] : null; }
774
+ // Does the hook still work? Run exactly what it runs, with --version in place of the hook's arguments: the same
775
+ // node lookup, the same baked path, the same npx fallback. A hook never looks sawbuck up on PATH, so neither does this.
776
+ function hookRuns(command) {
777
+ const probe = command.replace(/(sawbuck\.mjs"?|sawbuck@[\w.-]+) [^;]*/g, '$1 --version');
778
+ if (process.platform === 'win32') { const b = extractBaked(JSON.stringify(command)); return !!b && existsSync(b); }
779
+ try { return /^\d+\.\d+\.\d+/.test(execFileSync('sh', ['-c', probe], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 60000 }).trim()); } catch { return false; }
780
+ }
601
781
  function doctorRepo(repo) {
602
782
  const checks = [];
783
+ // the git hook tries a sawbuck on PATH first, then the baked path, then npx
603
784
  const pc = gitPath(repo, 'hooks/post-commit');
604
- try { if (existsSync(pc)) { const b = readFileSync(pc, 'utf8'); if (b.includes(S_START)) checks.push({ mode: 'git', file: pc, baked: extractBaked(b) }); } } catch {}
605
- for (const [mode, f] of [['claude', join(repo, '.claude', 'settings.local.json')], ['claude-shared', join(repo, '.claude', 'settings.json')]]) {
606
- try { if (existsSync(f)) { const b = readFileSync(f, 'utf8'); if (b.includes('sawbuck.mjs')) checks.push({ mode, file: f, baked: extractBaked(b) }); } } catch {}
785
+ try { if (existsSync(pc)) { const b = readFileSync(pc, 'utf8'); if (b.includes(S_START)) { const baked = extractBaked(b); let onPath = false; try { onPath = !!execFileSync('sh', ['-c', 'command -v sawbuck'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {} checks.push({ mode: 'git', file: pc, ok: onPath || (!!baked && existsSync(baked)) || /npx -y sawbuck@/.test(b) }); } } } catch {}
786
+ for (const [mode, f] of [['claude', join(repo, '.claude', 'settings.local.json')], ['claude-shared', join(repo, '.claude', 'settings.json')], ['user', join(homedir(), '.claude', 'settings.json')]]) {
787
+ let cfg = null; try { cfg = JSON.parse(readFileSync(f, 'utf8')); } catch { continue; }
788
+ const ours = (list) => (Array.isArray(list) ? list : []).flatMap((h) => (h && Array.isArray(h.hooks) ? h.hooks : [])).map((x) => x && x.command).filter((c) => typeof c === 'string' && OURS.test(c));
789
+ const cmds = ours(Object.values((cfg && cfg.hooks) || {}).flat());
790
+ if (!cmds.length) continue;
791
+ // the set attach writes, each under its own event: the rule on a prompt and after a compaction, the check on Stop
792
+ const at = (event, re, matcher) => ours(((cfg.hooks || {})[event] || []).filter((h) => !matcher || (h && h.matcher === matcher))).some((c) => re.test(c));
793
+ const missing = [at('UserPromptSubmit', /\bturn --hook\b/) ? '' : 'UserPromptSubmit', at('SessionStart', /\bturn --hook\b/, 'compact') ? '' : 'SessionStart[compact]', at('Stop', /__hook\b.*--stop\b/) ? '' : 'Stop with --stop'].filter(Boolean);
794
+ checks.push({ mode, file: f, ok: !missing.length && [...new Set(cmds)].every(hookRuns), missing });
607
795
  }
608
- if (!checks.length) { out(`sawbuck: no attachment in ${repo}, nothing to check.`); return; }
609
- const pathOk = onPath();
610
- let bad = 0;
796
+ // the receipt says hooks were wired here, and they are gone: a removal git status cannot show (the file is excluded, or under .git)
797
+ const receipt = Object.keys(readManifest(manifestPath(repo)).attachments || {}).filter((m) => !checks.some((c) => c.mode === m));
798
+ if (!checks.length && !receipt.length) { out(`sawbuck: no attachment in ${repo}, nothing to check.`); return; }
799
+ let bad = receipt.length;
800
+ const again = (m) => `sawbuck attach${{ git: ' --git', 'claude-shared': ' --shared', user: ' --user' }[m] || ''} again`;
611
801
  out(`sawbuck doctor, ${repo}:`);
612
802
  for (const c of checks) {
613
- const ok = pathOk || (c.baked ? existsSync(c.baked) : false);
614
- if (!ok) bad++;
615
- out(` ${ok ? 'OK ' : 'STALE'} ${c.mode.padEnd(14)} ${c.file}${ok ? '' : ` (baked path gone: ${c.baked || '?'} and sawbuck not on PATH, re-attach or install it)`}`);
803
+ if (!c.ok) bad++;
804
+ const why = c.ok ? '' : c.missing && c.missing.length ? ` (the hook set is incomplete, missing: ${c.missing.join(', ')}; run ${again(c.mode)})` : ` (the hook does not run: what it calls is gone; run ${again(c.mode)})`;
805
+ out(` ${c.ok ? 'OK ' : c.missing && c.missing.length ? 'PART ' : 'STALE'} ${c.mode.padEnd(14)} ${c.file}${why}`);
616
806
  }
617
- out(pathOk ? '\n sawbuck resolves on PATH, attachments survive a reinstall.' : '\n tip: `npm i -g sawbuck` so hooks resolve by PATH and survive an upgrade.');
807
+ for (const m of receipt) out(` GONE ${m.padEnd(14)} the receipt says ${m === 'git' ? 'a post-commit hook was wired here, but our block is gone' : 'hooks were wired here, but the hooks are gone'}. Run ${again(m)}, or sawbuck detach to drop the receipt.`);
618
808
  if (bad) process.exitCode = 1;
619
809
  }
810
+ function doctorAll() {
811
+ const r = regRead();
812
+ if (!r.repos.length) { out('sawbuck: not attached anywhere.'); return; }
813
+ out('sawbuck is attached to:');
814
+ for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.repo}`);
815
+ for (const repo of new Set(r.repos.map((x) => x.repo))) doctorRepo(repo);
816
+ }
817
+
818
+ // --- every attachment, in one list, so there is one way out of all of them ---
819
+ function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
820
+ function regRead() { if (planned.registry) return { ...planned.registry }; try { const r = JSON.parse(readFileSync(regPath(), 'utf8')); r.repos = (r.repos || []).filter((x) => existsSync(x.repo)); return r; } catch { return { repos: [] }; } } // a repo that is gone is not attached
821
+ function regWrite(r) {
822
+ const f = regPath();
823
+ if (dry) planned.registry = r;
824
+ if (!r.repos.length) { dropFile(f, 'the list of attached repos'); rmIfEmptyDir(dirname(f)); return; }
825
+ putFile(f, JSON.stringify(r, null, 2) + '\n', 'the list of attached repos');
826
+ }
827
+ function regAdd(repo, mode) { const r = regRead(); r.repos = r.repos.filter((x) => !(x.repo === repo && x.mode === mode)); r.repos.push({ repo, mode, at: new Date().toISOString() }); regWrite(r); }
828
+ function regRemove(repo, mode) { const r = regRead(); const keep = r.repos.filter((x) => !(x.mode === mode && (mode === 'user' || x.repo === repo))); if (keep.length !== r.repos.length) regWrite({ ...r, repos: keep }); }
829
+ function detachAll(purge) {
830
+ const r = regRead();
831
+ if (!r.repos.length) { say('sawbuck: nothing to detach: not attached anywhere.'); return; }
832
+ for (const x of [...r.repos]) {
833
+ if (x.mode === 'git') unwireGit(x.repo);
834
+ else if (x.mode === 'user') unwireClaude(x.repo, { user: true });
835
+ else unwireClaude(x.repo, {});
836
+ if (purge) purgeRecord(x.repo);
837
+ syncExclude(x.repo, readManifest(manifestPath(x.repo)));
838
+ }
839
+ say('sawbuck: detached everywhere.');
840
+ }
620
841
  // --- shared helpers ---------------------------------------------------------
621
- function dryText(plan) { return ['sawbuck --dry-run: would leave this footprint (writing nothing):', ...plan.map((p) => ' ' + p), ' all of it reversible with `detach`.'].join('\n'); }
842
+ function dryText(plan, verb = 'leave') { return verb === 'remove' ? ['sawbuck --dry-run: would remove (changing nothing):', ...(plan.length ? plan.map((p) => ' ' + p) : [' nothing: no wiring here'])].join('\n') : ['sawbuck --dry-run: would leave this footprint (writing nothing):', ...plan.map((p) => ' ' + p), ' all of it reversible with `detach`.'].join('\n'); }