sawbuck 0.1.45 → 0.1.47

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,20 +1,22 @@
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';
@@ -24,18 +26,41 @@ 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');
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
39
64
  // the record/state dirs the engines write into the worktree; excluded locally so
40
65
  // attach never dirties `git status`.
41
66
  // The record Sawbuck keeps on disk but must never let dirty git status: the engine records
@@ -63,23 +88,35 @@ Usage:
63
88
  commit, or the range you name). Zero-config, no attach, no hook. The
64
89
  fastest way to see it work. Every promptwheel run flag passes through.
65
90
  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/)
91
+ (--working with nothing uncommitted is 64 too)
92
+ sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block] [--force]
93
+ Write promptwheel.config.json: your detected tests and the tripwires, with
94
+ notes from what your git history shows actually breaks, plus a short guide in
95
+ AGENTS.md (--no-agent-block to skip). Wizard in a terminal, non-interactive in
96
+ a pipe/CI (or --yes). An existing config needs --force.
97
+ sawbuck attach [repo] [--user | --shared | --git] [--dry-run]
98
+ Check every Claude Code turn in this repo. --user: every repo that has a
99
+ promptwheel.config.json. --shared: a tracked settings file a team commits.
100
+ --git: a post-commit hook, for any other agent or none (commits in a linked
101
+ worktree are not checked). --dry-run prints every file it would write
102
+ sawbuck detach [repo] [--user] [--all] [--verify] [--purge] [--dry-run]
103
+ Remove the hooks (no flag: whatever is wired here; --all: every repo). The
104
+ record stays, kept out of git status; --purge deletes it. --verify proves the
105
+ wiring is gone and names what is left. --dry-run lists what would go
74
106
  sawbuck status [repo] The inventory: caught over time + debt accruing now + whether it is armed. --json
75
107
  --log [-n N] for the recent checks, newest first
76
108
  sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
77
109
  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)
110
+ shows only what a branch adds; --fail-on exits 1 when that debt (or, without
111
+ --since, any live debt of that kind) is there (a CI gate)
79
112
  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
113
+ sawbuck doctor [repo] Run what each hook runs (project and user level) and say if it still works,
114
+ is incomplete, or is gone. --all lists every repo it is attached to and checks each
115
+ sawbuck contract [--dry-run] [--full] [--strict]
116
+ Give this repo its rules: protected paths, the written rule, and your detected
117
+ tests when there is no config yet (--dry-run to preview). --full adds supply-chain
118
+ guards, the repo lock, and a CI check on a github.com repo with a real test
119
+ command; --strict makes them block
83
120
  sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
84
121
  sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
85
122
  sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
@@ -102,6 +139,11 @@ const repoRoot = () => gitTop(repoArg()) || repoArg();
102
139
  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
140
  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
141
  const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
142
+ // `detach --dry-run` walks the same path as the real detach and lists what it would touch, so the two cannot drift.
143
+ // 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.
144
+ const planned = { lines: new Map(), gone: new Set(), receipts: new Map(), registry: null };
145
+ const there = (p) => existsSync(p) && !planned.gone.has(p);
146
+ const say = (s) => { if (!dry) out(s); };
105
147
 
106
148
  if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
107
149
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
@@ -115,11 +157,29 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
115
157
  process.exit(0);
116
158
  }
117
159
 
160
+ // `sawbuck <command> --help`: that command's lines from the help, and nothing runs. Internal commands get the whole help.
161
+ if (rest.includes('--help') || rest.includes('-h')) {
162
+ const own = []; let cur = null;
163
+ 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); }
164
+ if (own.length || ['rank', 'frame', 'turn', '__hook'].includes(cmd)) { out(own.length ? `sawbuck ${version}\n\n${own.join('\n')}` : help); process.exit(0); }
165
+ }
166
+ // 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
167
+ // engine, which refuses the ones it does not know. --claude is what older installs pass: taken, not listed.
168
+ const KNOWN = {
169
+ contract: ['--dry-run', '--full', '--strict'],
170
+ init: ['--yes', '-y', '--wizard', '--no-investigate', '--no-agent-block', '--force', '--preset'],
171
+ attach: ['--user', '--shared', '--git', '--dry-run', '--claude'],
172
+ detach: ['--user', '--all', '--verify', '--purge', '--dry-run', '--claude'],
173
+ doctor: ['--all'],
174
+ status: ['--json', '--log', '-n'],
175
+ };
176
+ 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(', ')})`); }
177
+
118
178
  switch (cmd) {
119
179
  case 'contract': {
120
180
  const top = gitTop(repoArg());
121
181
  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' }); }
182
+ try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => KNOWN.contract.includes(a))], { stdio: 'inherit' }); }
123
183
  catch (e) { process.exit(e.status || 1); }
124
184
  break;
125
185
  }
@@ -131,14 +191,14 @@ switch (cmd) {
131
191
  case 'status': {
132
192
  // the inventory: one view unioning what the check has caught over time, what debt is accruing
133
193
  // 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' }); }
194
+ if (flags.has('--log')) { logReport(repoRoot(), rest); break; }
195
+ try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoRoot(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
136
196
  catch (e) { process.exit(e.status || 1); }
137
197
  break;
138
198
  }
139
199
  case 'check': {
140
200
  // 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
201
+ // zero-config (the engine finds npm, pytest, cargo and go tests), and say plainly whether the win was
142
202
  // earned. No attach, no hook, no record. The try-it-first on-ramp.
143
203
  // `sawbuck check` IS `promptwheel run` with a plain-spoken verdict: every engine flag passes
144
204
  // through (--base/--head/--repeat/--allow/--json/--no-detect-gaming), so there is one entry
@@ -148,7 +208,7 @@ switch (cmd) {
148
208
  for (let i = 0; i < rest.length; i++) {
149
209
  const a = rest[i];
150
210
  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; }
211
+ 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
212
  if (a.startsWith('--')) { pass.push(a); continue; }
153
213
  pos.push(a);
154
214
  }
@@ -159,24 +219,33 @@ switch (cmd) {
159
219
  // 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
220
  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
221
  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;
222
+ const uncommitted = g('status', '--porcelain').length > 0;
223
+ const dirty = !base && !head && uncommitted;
224
+ // --working on a clean tree would read the tree against the commit it already is, and call that held
225
+ 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
226
  // nothing to verify yet: a clean tree with no previous commit to diff against (a fresh repo). Say so
164
227
  // plainly instead of the generic "could not run" below, so the first run is not a dead end.
165
228
  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
229
  const where = dirty ? 'working' : (base || head) ? `${base || 'HEAD~1'}..${head || 'HEAD'}` : 'last';
167
230
  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); }
231
+ const { rep, code, why } = gateReport(repo, [...args, ...pass]);
232
+ if (!rep || !rep.verdict) { out(`🐴 sawbuck: could not run the check here: ${why || 'the engine gave no verdict'}`); process.exit(64); }
170
233
  if (json) { out(JSON.stringify(rep)); process.exit(code); }
171
234
  const m = (rep.metrics || [])[0];
172
235
  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
236
  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).`);
237
+ const noDetect = pass.includes('--no-detect-gaming');
238
+ 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.`);
239
+ 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.`);
240
+ 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
241
  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.');
242
+ else if (rep.verdict === 'gamed') {
243
+ out('🐴 🚩 gamed: a win here came from editing the test, config, or grader, not the source. It does NOT survive a source-only re-run, so it does not count.');
244
+ if (!dirty && /^(Sawbuck|Promptwheel)-Allow:/im.test(g('log', '--format=%B', `${base || 'HEAD~1'}..${head || 'HEAD'}`))) out('🐴 note: a Sawbuck-Allow trailer does not clear this, because an agent can write a commit message. A person passes --allow "<reason>".');
245
+ }
246
+ else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a protected path changed, however green the rest is.');
178
247
  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.');
248
+ else out(`🐴 ≈ inconclusive: ${inconclusiveWhy(rep, repo)}`);
180
249
  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
250
  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
251
  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 +302,9 @@ switch (cmd) {
233
302
  { // a baseline even with no rules yet: a turn that signs a contract is judged by the contract it began with
234
303
  const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
235
304
  const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
236
- const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
305
+ // a baseline attach wrote is not a prompt: it covers the turn that ran attach, if there was one. Set up from
306
+ // a terminal, nothing is in flight, and the commits made before this prompt are the person's.
307
+ const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince && prev.by !== 'attach';
237
308
  if (!unfinished) {
238
309
  const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
239
310
  const before = (st.lastViolation && st.lastViolation.before && typeof st.lastViolation.before === 'object') ? st.lastViolation.before : {};
@@ -272,32 +343,41 @@ switch (cmd) {
272
343
  break;
273
344
  }
274
345
  case 'attach': {
275
- const user = flags.has('--user');
346
+ const user = flags.has('--user'), shared = flags.has('--shared'), git = flags.has('--git');
347
+ if (user + shared + git > 1) usage('attach takes one of --user, --shared or --git');
276
348
  const top = gitTop(repoArg());
277
349
  if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
278
350
  const repo = top || repoArg();
279
- wireClaude(repo, { user });
351
+ if (git) wireGit(repo); else wireClaude(repo, { user, shared });
280
352
  if (!dry) verifyAttach(repo);
281
353
  break;
282
354
  }
283
355
  case 'detach': {
284
356
  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, {});
357
+ if (flags.has('--all')) detachAll(purge);
288
358
  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);
359
+ const repo = repoRoot();
360
+ if (flags.has('--user')) unwireClaude(repo, { user: true });
361
+ else if (flags.has('--claude')) unwireClaude(repo, {});
362
+ else {
363
+ // no flag: take out whatever is wired in this repo, older git hooks included. The receipt says what attach
364
+ // wrote; our hooks in the project settings or the post-commit hook count without one, so a lost receipt or a
365
+ // teammate's clone of a --shared repo is not left wired.
366
+ const a = readManifest(manifestPath(repo)).attachments || {};
367
+ const hasClaude = !!(a.claude || a['claude-shared']) || [false, true].some((shared) => hooksIn(claudeFile(repo, { shared }).f));
368
+ if (hasClaude) unwireClaude(repo, {});
369
+ if (a.git || hasBlock(repo) || !hasClaude) unwireGit(repo);
370
+ }
371
+ if (purge) purgeRecord(repo);
372
+ syncExclude(repo, readManifest(manifestPath(repo))); // after a purge the exclude lines go too
373
+ const left = recordLeft(repo);
374
+ if (left.length) say(`sawbuck: still on disk: ${left.join(', ')}. It is the record, kept out of git status; \`sawbuck detach --purge\` removes it.`);
375
+ if (flags.has('--verify') && !dry) verifyClean(repo);
295
376
  }
296
- if (purge) purgeRecord(repo);
297
- if (flags.has('--verify')) verifyClean(repo);
377
+ if (dry) out(dryText([...planned.lines.values()], 'remove'));
298
378
  break;
299
379
  }
300
- case 'doctor': { doctorRepo(repoRoot()); break; }
380
+ case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
301
381
  case 'rules': { out(rulesCli(rest)); break; }
302
382
  case 'shape': { out(shapeCli(rest)); break; }
303
383
  case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
@@ -388,51 +468,139 @@ function verifyAttach(repo) {
388
468
  const { rep } = gateReport(repo, ['run', '--base', base, '--head', 'HEAD']);
389
469
  const m = rep && (rep.metrics || [])[0];
390
470
  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.');
471
+ else out(` ⚠ I could not measure anything here yet: ${rep ? inconclusiveWhy(rep, repo) : 'the check did not run'} I stay quiet until then.`);
392
472
  }
393
473
 
394
474
  // --- the attach receipt (out of the worktree, inside .git) ------------------
395
475
  function manifestPath(repo) { return join(gitDir(repo), 'sawbuck', 'attach.json'); }
396
476
  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: {} }; } }
477
+ 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
478
  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');
479
+ if (dry) planned.receipts.set(p, m);
480
+ if (!Object.keys(m.attachments || {}).length) { dropFile(p, 'the attach receipt'); rmIfEmptyDir(dirname(p)); return; }
481
+ putFile(p, JSON.stringify(m, null, 2) + '\n', 'the attach receipt');
402
482
  }
403
483
 
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
-
484
+ // Every write and removal on the detach path goes through these two: under --dry-run each is listed instead of done.
485
+ function putFile(f, text, what) {
486
+ if (dry) { planned.lines.set(f, `modify ${f} (${what})`); return; }
487
+ mkdirSync(dirname(f), { recursive: true });
488
+ writeFileSync(f, text);
489
+ }
490
+ function dropFile(p, what) {
491
+ if (!there(p)) return;
492
+ if (dry) { planned.lines.set(p, `delete ${p} (${what})`); planned.gone.add(p); return; }
493
+ try { rmSync(p, { recursive: true, force: true }); } catch {}
494
+ }
495
+ function rmIfEmptyDir(d) { if (dry) return; try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
407
496
  // --- .git/info/exclude: keep the worktree clean without touching .gitignore --
408
497
  // Any attachment keeps the block, the last one out takes it. Returns whether the file changed.
409
498
  function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
410
- function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
499
+ // The lines stay while the record they hide is still on disk: clearing them would put .sawbuck/ in git status and a
500
+ // signed repo would fail its own hooks-frozen rule over Sawbuck's record. --purge deletes the record, then the lines.
501
+ function syncExclude(repo, manifest) {
502
+ const changed = Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo, dry) : hiddenLeft(repo).length ? false : clearExcluded(repo, dry);
503
+ if (dry && changed) planned.lines.set(excludeFile(repo), `modify ${excludeFile(repo)} (the lines that keep the record out of git status)`);
504
+ return changed;
505
+ }
506
+ // the record paths still on disk and not tracked (a committed LOGBOOK.md is the person's file)
507
+ function recordLeft(repo) {
508
+ const tracked = (p) => { try { return !!execFileSync('git', ['-C', repo, 'ls-files', '--', p], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return false; } };
509
+ return ['.sawbuck/', '.promptwheel/outcomes.jsonl', 'events.jsonl', 'annotations.jsonl', 'LOGBOOK.md', 'JOURNEY.md'].filter((p) => there(join(repo, p)) && !tracked(p));
510
+ }
511
+ // what the block hides that is still there: the record, and a settings.local.json the person keeps
512
+ function hiddenLeft(repo) {
513
+ const local = join(repo, '.claude', 'settings.local.json');
514
+ 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 {}
515
+ return [...recordLeft(repo), ...(own ? ['.claude/settings.local.json'] : [])];
516
+ }
411
517
 
412
- // --- git post-commit --------------------------------------------------------
413
- // Older versions could attach here. Nothing wires it now; detach still takes it out.
518
+ // --- git post-commit (attach --git) -----------------------------------------
519
+ // For any agent or none: every commit is checked, whatever made it. The hook prefers a `sawbuck` on PATH (so it
520
+ // survives a reinstall), then the absolute path of the sawbuck that attached, then the same version through npx.
521
+ function hookBlock(repo) {
522
+ const r = JSON.stringify(repo);
523
+ // git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check shells out to git and would
524
+ // inherit them, operate on the wrong repo, and silently catch nothing. Clear them first, in a subshell: the block
525
+ // sits above the person's own hook lines, and those keep what git gave them.
526
+ 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`;
527
+ }
528
+ // The first commit after attach is checked only if there is a commit to check it from: HEAD as it stands now.
529
+ // A baseline other hooks are keeping (Claude hooks wired here or at user level, a turn in flight) is left alone;
530
+ // one left behind by an attachment that is gone is not, or commits made while nothing watched would be laid on the next one.
531
+ function gitBaseline(repo) {
532
+ let sha = ''; try { sha = execFileSync('git', ['-C', repo, 'rev-parse', '--verify', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
533
+ const path = join(repo, '.sawbuck', 'state.json');
534
+ let st = {}; try { st = JSON.parse(readFileSync(path, 'utf8')); } catch {}
535
+ const kept = (st.lastSha || (st.turnBaseline && st.turnBaseline.sha)) && (Object.keys(readManifest(manifestPath(repo)).attachments || {}).length || hooksIn(claudeFile(repo, { user: true }).f));
536
+ return { path, st, sha: kept ? '' : sha };
537
+ }
538
+ function wireGit(repo) {
539
+ const pc = gitPath(repo, 'hooks/post-commit');
540
+ // core.hooksPath inside the work tree (the husky way): the team tracks those files or a hook tool rewrites them, and
541
+ // our block pins this machine's paths. Refuse, and give the portable line a person can add.
542
+ 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`);
543
+ const existed = existsSync(pc);
544
+ const was = existed ? readFileSync(pc, 'utf8') : '';
545
+ if (was.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
546
+ // our block is shell, and git only runs an executable hook: a hook in another language, or one git is not running,
547
+ // is left exactly as it is
548
+ 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`);
549
+ 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.`); }
550
+ // Our block goes right after the shebang line, so a hook that ends in `exit 0` still reaches it. The receipt notes
551
+ // the shebang or the newline we had to add, so detach leaves a hook that was here before byte for byte as it was.
552
+ const shebang = !was.startsWith('#!'), nl = !shebang && !was.includes('\n');
553
+ const body = (shebang ? '#!/bin/sh\n' : '') + was + (nl ? '\n' : '');
554
+ const at = body.indexOf('\n') + 1;
555
+ const mp = manifestPath(repo), base = gitBaseline(repo);
556
+ if (dry) {
557
+ out(dryText([
558
+ `${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
559
+ ensureExcluded(repo, true) ? `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})` : null,
560
+ `${existsSync(mp) ? 'modify' : 'create'} ${mp} (attach receipt)`,
561
+ base.sha ? `write ${base.path} (the commit to check from, so the first commit after this is checked)` : null,
562
+ `write ${regPath()} (the list of attached repos, for detach --all)`,
563
+ ].filter(Boolean)));
564
+ return;
565
+ }
566
+ writeFileSync(pc, body.slice(0, at) + hookBlock(repo) + body.slice(at));
567
+ chmodSync(pc, 0o755);
568
+ const m = readManifest(mp);
569
+ m.attachments = { ...m.attachments, git: { pc: { created: !existed, ...(existed && shebang ? { shebang } : {}), ...(nl ? { nl } : {}) } } };
570
+ syncExclude(repo, m);
571
+ writeManifest(mp, m);
572
+ 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 {} }
573
+ regAdd(repo, 'git');
574
+ out(`sawbuck: attached (git post-commit) in ${repo}. Every commit is checked, whatever made it.`);
575
+ }
576
+ function hasBlock(repo) { try { return readFileSync(gitPath(repo, 'hooks/post-commit'), 'utf8').includes(S_START); } catch { return false; } }
414
577
  function unwireGit(repo) {
415
578
  const pc = gitPath(repo, 'hooks/post-commit');
416
579
  const m = readManifest(manifestPath(repo));
417
- const hasReceipt = !!m.attachments?.git;
580
+ const rec = m.attachments?.git;
581
+ const wired = hasBlock(repo);
418
582
  // 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;
583
+ if (!wired && !rec) { say(syncExclude(repo, m) ? `sawbuck: removed the record lines from ${excludeFile(repo)}` : 'sawbuck: nothing to detach'); return; }
584
+ if (wired) {
585
+ // take out what attach put in, and nothing else: a hook that was here before is the same bytes again
586
+ let rest = readFileSync(pc, 'utf8').replace(BLOCK_RE, '');
587
+ if (rec?.pc?.shebang && rest.startsWith('#!/bin/sh\n')) rest = rest.slice('#!/bin/sh\n'.length);
588
+ if (rec?.pc?.nl && rest.endsWith('\n')) rest = rest.slice(0, -1);
423
589
  // 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);
590
+ if ((rec?.pc?.created || !rec) && isTrivialHook(rest)) dropFile(pc, 'the post-commit hook attach created');
591
+ else putFile(pc, rest, 'our block in the post-commit hook');
426
592
  }
427
- if (hasReceipt) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
428
- out(`sawbuck: detached (git) from ${repo}`);
593
+ if (rec) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
594
+ regRemove(repo, 'git');
595
+ say(`sawbuck: detached (git) from ${repo}`);
429
596
  }
430
597
  function isTrivialHook(text) { return text.replace(/^#!.*\n?/, '').trim() === ''; }
431
598
 
432
599
  // --- Claude Code hooks ------------------------------------------------------
433
600
  // 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.
601
+ // Project default writes the gitignored settings.local.json. --shared writes the tracked settings.json for a team:
602
+ // its hooks run the pinned version through npx and find the repo from where they run, so the file holds no path from
603
+ // this machine, and removing the hooks shows in git. Safe merge; refuses malformed JSON; idempotent.
436
604
  function claudeFile(repo, { user, shared }) {
437
605
  const dir = join(user ? homedir() : repo, '.claude');
438
606
  const name = user || shared ? 'settings.json' : 'settings.local.json';
@@ -446,12 +614,12 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
446
614
  return `"$(command -v node || echo ${JSON.stringify(execPath).replace(/^"|"$/g, "'")})"`;
447
615
  }
448
616
  function wireClaude(repo, opts) {
449
- const { user } = opts;
617
+ const { user, shared } = opts;
450
618
  // user-level hooks already cover every repo; a second set would run each check twice and spend the
451
619
  // send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
452
- if (!user) {
620
+ if (!user && !shared) {
453
621
  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 {}
622
+ 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
623
  }
456
624
  const { dir, f } = claudeFile(repo, opts);
457
625
  const dirExisted = existsSync(dir);
@@ -461,30 +629,41 @@ function wireClaude(repo, opts) {
461
629
  // run through npx, the installed path lives in a cache that can be cleared, and a missing hook fails quietly and
462
630
  // leaves the wall off: fall back to fetching the same version
463
631
  const run = (args) => {
632
+ if (shared) return `npx -y sawbuck@${version} ${args}`;
464
633
  const direct = `${nodeCmd()} ${JSON.stringify(SELF)} ${args}`;
465
634
  return process.platform !== 'win32' && SELF.includes('/_npx/') ? `if [ -f ${JSON.stringify(SELF)} ]; then ${direct}; else npx -y sawbuck@${version} ${args}; fi` : direct;
466
635
  };
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`);
636
+ const here = user || shared; // no repo path in the command: the hook works out the repo from where it runs
637
+ const ctx = run(here ? 'turn --hook' : `turn --hook ${JSON.stringify(repo)}`);
638
+ const chk = run(here ? '__hook --by claude --stop' : `__hook --repo ${JSON.stringify(repo)} --by claude --stop`);
469
639
  // attached already? "already attached" only when every hook of THIS version is present; an older
470
640
  // attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
471
641
  let upgrading = false;
472
- if (JSON.stringify(cfg).includes('sawbuck.mjs')) {
642
+ if (OURS.test(JSON.stringify(cfg))) {
473
643
  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; }
644
+ if (has('UserPromptSubmit', ctx) && has('SessionStart', ctx, 'compact') && has('Stop', chk)) { out(`sawbuck: already attached (${user ? 'user' : shared ? 'shared' : 'claude'}) in ${f}`); return; }
475
645
  upgrading = true;
476
646
  }
647
+ const mode = user ? 'user' : shared ? 'claude-shared' : 'claude';
648
+ const mp = user ? userManifestPath() : manifestPath(repo);
649
+ const stPath = join(repo, '.sawbuck', 'state.json');
650
+ let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
651
+ // attach often runs inside a turn (saying yes to the offer), and that turn's Stop hook then has no baseline, so the
652
+ // files signing just wrote would read as the agent's violations. What is here now is the baseline; anything after it counts.
653
+ const baseline = !user && existsSync(join(repo, 'promptwheel.config.json')) && !st.turnBaseline;
477
654
  if (dry) {
478
655
  out(dryText([
479
656
  `${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)`,
657
+ !user && ensureExcluded(repo, true) ? `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})` : null,
658
+ `${existsSync(mp) ? 'modify' : 'create'} ${mp} (attach receipt)`,
659
+ baseline ? `write ${stPath} (the turn baseline, so this turn's own signing is not read as the agent's)` : null,
660
+ `write ${regPath()} (the list of attached repos, for detach --all)`,
482
661
  ].filter(Boolean)));
483
662
  return;
484
663
  }
485
664
  cfg.hooks = cfg.hooks || {};
486
665
  const put = (event, command, matcher) => {
487
- cfg.hooks[event] = (cfg.hooks[event] || []).filter((h) => !JSON.stringify(h).includes('sawbuck.mjs'));
666
+ cfg.hooks[event] = (cfg.hooks[event] || []).filter((h) => !OURS.test(JSON.stringify(h)));
488
667
  cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
489
668
  };
490
669
  put('UserPromptSubmit', ctx);
@@ -494,30 +673,24 @@ function wireClaude(repo, opts) {
494
673
  put('Stop', chk);
495
674
  mkdirSync(dir, { recursive: true });
496
675
  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'))) {
676
+ if (baseline) {
500
677
  try {
501
678
  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
- }
679
+ let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
680
+ st.turnBaseline = { sha, files: protectedDirty(repo, { store: true }), by: 'attach' };
681
+ st.lastPromptAt = new Date().toISOString();
682
+ mkdirSync(dirname(stPath), { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
510
683
  } catch {}
511
684
  }
512
- const mode = user ? 'user' : 'claude';
513
- const mp = user ? userManifestPath() : manifestPath(repo);
514
685
  const m = readManifest(mp);
515
686
  const prev = (m.attachments || {})[mode]; // an upgrade keeps the receipt of what the first attach created
516
687
  m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: prev ? !!prev.fileCreated : !fileExisted, dirCreated: prev ? !!prev.dirCreated : !dirExisted } };
517
688
  if (!user) syncExclude(repo, m);
518
689
  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}`);
690
+ regAdd(repo, mode);
691
+ const kind = user ? 'USER-LEVEL' : shared ? 'shared project' : 'project';
692
+ out(upgrading ? `sawbuck: updated the ${kind} Claude Code hooks in ${f} to this version` : `sawbuck: wired ${kind} Claude Code hooks in ${f}`);
693
+ if (shared) out(` commit ${relative(repo, f)} so the team gets the check. It runs sawbuck ${version} through npx on every machine.`);
521
694
  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
695
  if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
523
696
  }
@@ -531,13 +704,13 @@ function unwireClaude(repo, { user }) {
531
704
  const f = rec?.file || claudeFile(repo, { user, shared: mode === 'claude-shared' }).f;
532
705
  if (existsSync(f)) {
533
706
  let raw = ''; try { raw = readFileSync(f, 'utf8'); } catch {}
534
- if (raw.includes('sawbuck.mjs')) {
707
+ if (OURS.test(raw)) {
535
708
  let cfg = null; try { cfg = JSON.parse(raw); } catch {}
536
709
  if (cfg) {
537
710
  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}`);
711
+ if ((rec?.fileCreated || !rec) && isTrivialSettings(cfg)) { dropFile(f, 'our Claude Code hooks were all it held'); if (rec?.dirCreated) rmIfEmptyDir(dirname(f)); }
712
+ else putFile(f, JSON.stringify(cfg, null, 2) + '\n', 'our Claude Code hooks');
713
+ say(`sawbuck: removed Claude Code hooks from ${f}`);
541
714
  did = true;
542
715
  }
543
716
  }
@@ -545,77 +718,120 @@ function unwireClaude(repo, { user }) {
545
718
  if (rec) { delete m.attachments[mode]; touched = true; }
546
719
  }
547
720
  if (touched) { if (!user) syncExclude(repo, m); writeManifest(mp, m); }
548
- if (!did && !touched) out('sawbuck: no Claude attachment found to detach');
721
+ for (const mode of modes) regRemove(repo, mode);
722
+ if (!did && !touched) say('sawbuck: no Claude attachment found to detach');
549
723
  }
550
724
  function removeClaudeEntries(cfg) {
551
725
  if (!cfg.hooks) return;
552
726
  for (const ev of ['UserPromptSubmit', 'SessionStart', 'Stop']) {
553
727
  if (!Array.isArray(cfg.hooks[ev])) continue;
554
- cfg.hooks[ev] = cfg.hooks[ev].filter((h) => !JSON.stringify(h).includes('sawbuck.mjs'));
728
+ cfg.hooks[ev] = cfg.hooks[ev].filter((h) => !OURS.test(JSON.stringify(h)));
555
729
  if (!cfg.hooks[ev].length) delete cfg.hooks[ev]; // no empty [] residue
556
730
  }
557
731
  if (cfg.hooks && !Object.keys(cfg.hooks).length) delete cfg.hooks;
558
732
  }
559
733
  function isTrivialSettings(cfg) { return Object.keys(cfg).length === 0; }
560
734
 
561
- // --- trace / verify / doctor / purge: show, prove, and clean the footprint --
735
+ // --- verify / doctor / purge: prove and clean the footprint ----------------
562
736
  function footprint(repo) {
563
737
  const items = [];
564
738
  const pc = gitPath(repo, 'hooks/post-commit');
565
739
  try { if (existsSync(pc) && readFileSync(pc, 'utf8').includes(S_START)) items.push({ what: pc, state: 'git hook' }); } catch {}
566
740
  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 {}
741
+ try { if (existsSync(f) && OURS.test(readFileSync(f, 'utf8'))) items.push({ what: f, state: 'claude hooks' }); } catch {}
568
742
  }
569
743
  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)' });
744
+ // the exclude lines are part of the record while it is on disk (syncExclude), wiring only once it is gone
745
+ try { if (existsSync(xf) && readFileSync(xf, 'utf8').includes(S_START) && !hiddenLeft(repo).length) items.push({ what: xf, state: 'local exclude' }); } catch {}
572
746
  const mp = manifestPath(repo);
573
747
  if (existsSync(mp)) { const m = readManifest(mp); items.push({ what: mp, state: `receipt: ${Object.keys(m.attachments || {}).join(', ') || 'empty'}` }); }
574
748
  return items;
575
749
  }
576
750
  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).');
751
+ // the record may stay (your data, unless --purge, and named above); the mechanism must be gone.
752
+ const left = footprint(repo);
753
+ if (!left.length) out('sawbuck: verified, the hooks and the receipt are gone.');
580
754
  else { out('sawbuck: NOT clean, these remain:'); for (const it of left) out(` ${it.state.padEnd(22)} ${it.what}`); process.exitCode = 1; }
581
755
  }
582
756
  function purgeRecord(repo) {
583
757
  let n = 0;
758
+ const purge = (p) => { if (!there(p)) return; dropFile(p, 'the record'); n++; say(`sawbuck: purged ${p}`); };
584
759
  for (const d of RECORD_DIRS) {
585
- const p = join(repo, d);
586
760
  // a committed LOGBOOK.md or JOURNEY.md is the person's file, not our record
587
761
  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 {}
762
+ purge(join(repo, d));
589
763
  }
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; }
764
+ purge(join(repo, '.promptwheel', 'outcomes.jsonl')); // the rest of .promptwheel/ is the signed contract: yours to keep or delete
765
+ if (!n) say('sawbuck: no record to purge');
598
766
  }
599
767
  // 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
768
  function extractBaked(text) { const m = text.match(/"([^"]*sawbuck\.mjs)\\?"/); return m ? m[1] : null; }
769
+ // Does the hook still work? Run exactly what it runs, with --version in place of the hook's arguments: the same
770
+ // node lookup, the same baked path, the same npx fallback. A hook never looks sawbuck up on PATH, so neither does this.
771
+ function hookRuns(command) {
772
+ const probe = command.replace(/(sawbuck\.mjs"?|sawbuck@[\w.-]+) [^;]*/g, '$1 --version');
773
+ if (process.platform === 'win32') { const b = extractBaked(JSON.stringify(command)); return !!b && existsSync(b); }
774
+ try { return /^\d+\.\d+\.\d+/.test(execFileSync('sh', ['-c', probe], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 60000 }).trim()); } catch { return false; }
775
+ }
601
776
  function doctorRepo(repo) {
602
777
  const checks = [];
778
+ // the git hook tries a sawbuck on PATH first, then the baked path, then npx
603
779
  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 {}
780
+ 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 {}
781
+ 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')]]) {
782
+ let cfg = null; try { cfg = JSON.parse(readFileSync(f, 'utf8')); } catch { continue; }
783
+ 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));
784
+ const cmds = ours(Object.values((cfg && cfg.hooks) || {}).flat());
785
+ if (!cmds.length) continue;
786
+ // the set attach writes, each under its own event: the rule on a prompt and after a compaction, the check on Stop
787
+ const at = (event, re, matcher) => ours(((cfg.hooks || {})[event] || []).filter((h) => !matcher || (h && h.matcher === matcher))).some((c) => re.test(c));
788
+ 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);
789
+ checks.push({ mode, file: f, ok: !missing.length && [...new Set(cmds)].every(hookRuns), missing });
607
790
  }
608
- if (!checks.length) { out(`sawbuck: no attachment in ${repo}, nothing to check.`); return; }
609
- const pathOk = onPath();
610
- let bad = 0;
791
+ // the receipt says hooks were wired here, and they are gone: a removal git status cannot show (the file is excluded, or under .git)
792
+ const receipt = Object.keys(readManifest(manifestPath(repo)).attachments || {}).filter((m) => !checks.some((c) => c.mode === m));
793
+ if (!checks.length && !receipt.length) { out(`sawbuck: no attachment in ${repo}, nothing to check.`); return; }
794
+ let bad = receipt.length;
795
+ const again = (m) => `sawbuck attach${{ git: ' --git', 'claude-shared': ' --shared', user: ' --user' }[m] || ''} again`;
611
796
  out(`sawbuck doctor, ${repo}:`);
612
797
  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)`}`);
798
+ if (!c.ok) bad++;
799
+ 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)})`;
800
+ out(` ${c.ok ? 'OK ' : c.missing && c.missing.length ? 'PART ' : 'STALE'} ${c.mode.padEnd(14)} ${c.file}${why}`);
616
801
  }
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.');
802
+ 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
803
  if (bad) process.exitCode = 1;
619
804
  }
805
+ function doctorAll() {
806
+ const r = regRead();
807
+ if (!r.repos.length) { out('sawbuck: not attached anywhere.'); return; }
808
+ out('sawbuck is attached to:');
809
+ for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.repo}`);
810
+ for (const repo of new Set(r.repos.map((x) => x.repo))) doctorRepo(repo);
811
+ }
812
+
813
+ // --- every attachment, in one list, so there is one way out of all of them ---
814
+ function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
815
+ 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
816
+ function regWrite(r) {
817
+ const f = regPath();
818
+ if (dry) planned.registry = r;
819
+ if (!r.repos.length) { dropFile(f, 'the list of attached repos'); rmIfEmptyDir(dirname(f)); return; }
820
+ putFile(f, JSON.stringify(r, null, 2) + '\n', 'the list of attached repos');
821
+ }
822
+ 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); }
823
+ 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 }); }
824
+ function detachAll(purge) {
825
+ const r = regRead();
826
+ if (!r.repos.length) { say('sawbuck: nothing to detach: not attached anywhere.'); return; }
827
+ for (const x of [...r.repos]) {
828
+ if (x.mode === 'git') unwireGit(x.repo);
829
+ else if (x.mode === 'user') unwireClaude(x.repo, { user: true });
830
+ else unwireClaude(x.repo, {});
831
+ if (purge) purgeRecord(x.repo);
832
+ syncExclude(x.repo, readManifest(manifestPath(x.repo)));
833
+ }
834
+ say('sawbuck: detached everywhere.');
835
+ }
620
836
  // --- 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'); }
837
+ 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'); }