sawbuck 0.1.43 → 0.1.45

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,28 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  // sawbuck, the CLI. Zero-dep.
3
- // `attach` wires Sawbuck's reflex into a repo (a git post-commit hook by default,
4
- // or Claude Code hooks); `detach` removes it and leaves the repo exactly as it
5
- // was. Isolation invariant: the repo never imports Sawbuck, and detach is a true
6
- // inverse of attach. Rides two engines: promptwheel (the check) + @promptwheel/logbook.
3
+ // `attach` wires the Claude Code hooks into a repo; `detach` removes them and leaves the repo exactly as it was. The
4
+ // repo never imports Sawbuck. Rides two engines: promptwheel (the check) + @promptwheel/logbook.
7
5
  //
8
- // How removal stays honest: attach writes a receipt to .git/sawbuck/attach.json
9
- // recording every file/dir it created or modified and every .git/info/exclude
10
- // line it added. detach reads the receipt and reverses exactly that, deleting
11
- // what it created, stripping only its own lines from what it modified, touching
12
- // nothing it did not touch. `trace` prints the footprint; `detach --verify`
13
- // proves the repo came back clean; `doctor` checks the attachment still resolves.
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.
14
8
 
15
9
  import { execFileSync } from 'node:child_process';
16
- import { erasedBlock } from './rewrites.mjs';
10
+ import { erasedBlock, erasedCommits } from './rewrites.mjs';
17
11
  import { steersReport } from './steers.mjs';
18
12
  import { rulesCli } from './rules.mjs';
19
13
  import { shapeCli } from './shape.mjs';
20
- import { ensureExcluded, RECORD_DIRS } from './exclude.mjs';
14
+ import { ensureExcluded, clearExcluded, EXCLUDED, RECORD_DIRS } from './exclude.mjs';
15
+ import { repoState } from './repo-state.mjs';
21
16
  import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
22
17
  import { basename, join, dirname, resolve } from 'node:path';
23
18
  import { fileURLToPath } from 'node:url';
24
19
  import { homedir } from 'node:os';
25
- import { recall as memRecall, conform as memConform } from './memory.mjs';
26
20
  import { PW } from './bins.mjs';
27
21
  import { frameBlock, mandatesInForce, protectedDirty, blobAt, lockState } from './contract.mjs';
28
22
 
@@ -53,7 +47,7 @@ const argv = process.argv.slice(2);
53
47
  const cmd = argv[0];
54
48
  const rest = argv.slice(1);
55
49
  const flags = new Set(rest.filter((a) => a.startsWith('--')));
56
- // `-n N` (sawbuck log) is a flag with a value, not a repo path.
50
+ // `-n N` (status --log) is a flag with a value, not a repo path.
57
51
  const positional = rest.filter((a, i) => !a.startsWith('--') && a !== '-n' && rest[i - 1] !== '-n');
58
52
  const repoArg = () => resolve(positional[0] || '.');
59
53
  const dry = flags.has('--dry-run');
@@ -61,7 +55,7 @@ const dry = flags.has('--dry-run');
61
55
  let version = '0.0.0';
62
56
  try { version = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).version; } catch {}
63
57
 
64
- const help = `sawbuck ${version}: sign your agent to a contract
58
+ const help = `sawbuck ${version}: write down the rules your agent follows, and prove they held
65
59
 
66
60
  Usage:
67
61
  sawbuck check [repo] [--base R --head R] [--repeat N] [--allow "<reason>"] [--json]
@@ -70,42 +64,29 @@ Usage:
70
64
  fastest way to see it work. Every promptwheel run flag passes through.
71
65
  Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
72
66
  sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
73
- Customize the contract (promptwheel.config.json), with notes from what your
74
- git history shows actually breaks, plus a short contract guide in AGENTS.md
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
75
69
  (--no-agent-block to skip). Wizard in a terminal, non-interactive in a pipe/CI (or --yes).
76
- sawbuck attach [repo] Enforce the contract on every commit (a git post-commit hook)
77
- --claude instead on every Claude Code turn (project hooks in .claude/settings.local.json)
78
- --shared with --claude, write the tracked .claude/settings.json (team-shared, visible in git)
79
- --user user-level Claude hooks that follow your cwd (attach once, every project)
80
- --dry-run print the exact footprint it would leave, and write nothing
81
- sawbuck detach [repo] [--claude | --user] [--all] [--verify] [--purge]
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]
82
72
  Remove it and restore the repo (no flag: whatever is attached here); --verify
83
73
  proves it came back clean; --purge also deletes the record (.sawbuck/)
84
74
  sawbuck status [repo] The inventory: caught over time + debt accruing now + whether it is armed. --json
85
- sawbuck log [repo] [-n N] Recent checks, newest first (verified / gamed / regression)
75
+ --log [-n N] for the recent checks, newest first
86
76
  sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
87
77
  The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
88
78
  shows only what a branch adds; --fail-on exits nonzero (a CI gate)
89
79
  sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
90
- sawbuck frame [repo] The contract's protected paths, inherited ones included, as one compact block;
91
- silent without a contract (--speak explains)
92
- sawbuck turn [repo] frame + context in one block: what attach --claude runs on every prompt
93
-
94
- sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
95
- sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
96
- sawbuck where List every repo Sawbuck is attached to
97
- sawbuck contract Sign this repo: tests, tripwires, protected paths, and the architecture lock (--dry-run to preview)
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
98
83
  sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
99
84
  sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
100
85
  sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
101
- sawbuck shape --check F Draw your steering shape from the labels, keeping only the totals
86
+ sawbuck shape --check F Draw your steering shape from the labels, keeping only the totals (--png for an image)
102
87
  sawbuck steers What you keep correcting, from the plugin's scratchpad on this machine (--json)
103
- sawbuck statusline One-line segment for your status line (🐴 watching | N caught | steer %)
88
+ sawbuck statusline One-line segment for your status line (🐴 watching | N caught)
104
89
 
105
- sawbuck recall [repo] Related notes for your changes from extra memory providers
106
- (SAWBUCK_MEMORY=logbook,obsidian + OBSIDIAN_VAULT), alongside context
107
- sawbuck memory-conformance <bin>
108
- Check a provider bin speaks the Memory Contract (recall/debt/record)
109
90
  sawbuck --help | --version
110
91
 
111
92
  Engines: promptwheel (the check) + @promptwheel/logbook (memory). Zero-LLM, offline, free.`;
@@ -124,9 +105,9 @@ const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
124
105
 
125
106
  if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
126
107
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
127
- out(flags.has('--all') ? help : `sawbuck ${version}: sign your agent to a contract
108
+ out(flags.has('--all') ? help : `sawbuck ${version}: write down the rules your agent follows, and prove they held
128
109
 
129
- sawbuck contract sign this repo: protected paths, your tests, CI
110
+ sawbuck contract give this repo its rules: protected paths, your tests
130
111
  sawbuck check verify your latest change right now
131
112
  sawbuck status what it has caught here
132
113
 
@@ -137,8 +118,8 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
137
118
  switch (cmd) {
138
119
  case 'contract': {
139
120
  const top = gitTop(repoArg());
140
- if (!top) usage(`${repoArg()} is not a git repository (a contract signs one; run it inside your project)`);
141
- try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => ['--dry-run', '--strict', '--no-ci'].includes(a))], { stdio: 'inherit' }); }
121
+ 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' }); }
142
123
  catch (e) { process.exit(e.status || 1); }
143
124
  break;
144
125
  }
@@ -149,7 +130,8 @@ switch (cmd) {
149
130
  }
150
131
  case 'status': {
151
132
  // the inventory: one view unioning what the check has caught over time, what debt is accruing
152
- // now, and whether it is armed here. The "show me everything" face.
133
+ // 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; }
153
135
  try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoArg(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
154
136
  catch (e) { process.exit(e.status || 1); }
155
137
  break;
@@ -192,7 +174,7 @@ switch (cmd) {
192
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).`);
193
175
  else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
194
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.');
195
- else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a hard rule in the contract was broken, however green the rest is.');
177
+ else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a rule in the contract was broken, however green the rest is.');
196
178
  else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
197
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.');
198
180
  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.');
@@ -200,25 +182,6 @@ switch (cmd) {
200
182
  if (rep.gate && rep.gate.changed) out(`🐴 ⚠ the gate definition changed in this change (fingerprint ${rep.gate.fingerprint.base} -> ${rep.gate.fingerprint.head}): a metric, guard, evaluatorPath or mandate was added, removed or edited.`);
201
183
  process.exit(code);
202
184
  }
203
- case 'log': {
204
- // the event feed: a running log of what the check did, so you can watch it work.
205
- const repo = repoArg();
206
- let lines = [];
207
- try { lines = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').split('\n').filter(Boolean); } catch {}
208
- if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
209
- const ni = rest.indexOf('-n');
210
- const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
211
- const icon = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
212
- const recent = lines.slice(-n).reverse();
213
- out(`sawbuck activity (last ${recent.length}):`);
214
- for (const l of recent) {
215
- try {
216
- const e = JSON.parse(l);
217
- out(` ${(e.at || '').slice(0, 16).replace('T', ' ')} ${icon[e.kind] || '·'} ${String(e.kind).padEnd(11)} ${e.file || ''}${e.reason ? ' ' + e.reason : ''}`);
218
- } catch {}
219
- }
220
- break;
221
- }
222
185
  case 'rank': {
223
186
  try { execFileSync(process.execPath, [join(HERE, 'rank.mjs'), ...rest], { stdio: 'inherit' }); }
224
187
  catch (e) { process.exit(e.status || 1); }
@@ -241,9 +204,10 @@ switch (cmd) {
241
204
  break;
242
205
  }
243
206
  case 'turn': {
244
- // the ONE per-prompt hook: the contract frame (forward: what must hold) and the relevant history
245
- // (backward: what broke here before) in a single block, one process per prompt. Silent when
246
- // there is nothing to say. Wired by `attach --claude` on UserPromptSubmit and after a compaction.
207
+ // the ONE per-prompt hook. The agent gets the contract frame once a session and again after a compaction: one
208
+ // written statement held in every bench, so saying it on each prompt only spends context. The relevant history
209
+ // is on demand (`sawbuck context`), and per turn only with {"turn":{"context":true}} in sawbuck.config.json.
210
+ // Wired by `attach` on UserPromptSubmit and after a compaction.
247
211
  // the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
248
212
  // subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
249
213
  const top = gitTop(repoArg());
@@ -258,8 +222,8 @@ switch (cmd) {
258
222
  // a file still carrying the content the last violation flagged is never baselined; and the
259
223
  // baseline's commit does not advance past a flagged commit until a clean stop or a commit the
260
224
  // person made between turns that no longer carries the flagged content.
261
- let hookEvent = '';
262
- if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
225
+ let hookEvent = '', session = '', framed = false;
226
+ if (flags.has('--hook') && !process.stdin.isTTY) { try { const h = JSON.parse(readFileSync(0, 'utf8') || '{}'); hookEvent = String(h.hook_event_name || ''); session = String(h.session_id || ''); } catch {} }
263
227
  if (hookEvent === 'UserPromptSubmit' && top) {
264
228
  try {
265
229
  ensureExcluded(repo); // before the first write: under user-level hooks nothing else has done it yet
@@ -290,40 +254,19 @@ switch (cmd) {
290
254
  st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo), rules: mandatesInForce(repo) };
291
255
  }
292
256
  }
257
+ framed = !!session && st.framedSession === session;
258
+ if (session) st.framedSession = session;
293
259
  st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
294
260
  mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
295
261
  } catch { /* the frame never fails a prompt */ }
296
262
  }
297
- const o = [frameBlock(repo), contextBlock(repo, false)].filter(Boolean).join('\n');
263
+ let history = false;
264
+ try { history = JSON.parse(readFileSync(join(repo, 'sawbuck.config.json'), 'utf8')).turn?.context === true; } catch {}
265
+ if (!history && top) erasedCommits(repo); // the ledger outlives the reflog whether or not it is printed
266
+ const o = [framed ? '' : frameBlock(repo), history ? contextBlock(repo, false) : ''].filter(Boolean).join('\n');
298
267
  if (o) out(o);
299
268
  break;
300
269
  }
301
- case 'recall': {
302
- // the notes face: supplementary relevant notes for your current changes, from any non-git memory
303
- // provider (e.g. an Obsidian vault). On-demand. Git history + live debt come from context / debt.
304
- const repo = repoRoot();
305
- const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
306
- let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
307
- if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
308
- if (!changed.length) { out('🐴 sawbuck: nothing changed to look up.'); break; }
309
- const notes = memRecall(repo, changed);
310
- if (!notes.length) { out('🐴 sawbuck: no related notes (configure extra memory providers with SAWBUCK_MEMORY, e.g. logbook,obsidian; logbook history is in `sawbuck context`).'); break; }
311
- out(`🐴 related notes for ${changed.length} changed path${changed.length === 1 ? '' : 's'} (leads, not conclusions):`);
312
- for (const n of notes) out(` - [${n.source}] ${n.summary}${n.ref ? ` (${n.ref})` : ''}`);
313
- break;
314
- }
315
- case 'memory-conformance': {
316
- // check that a provider bin speaks the Memory Contract (recall returns Finding[]; debt returns
317
- // Suppression[] or exits nonzero to signal it is not a git-truth provider). Read-only probe.
318
- const bin = positional[0];
319
- if (!bin) die('usage: sawbuck memory-conformance <provider-bin> (a bin speaking the contract verbs recall/debt/record)');
320
- const r = memConform(bin, process.cwd());
321
- out(`🐴 memory-conformance: ${bin}`);
322
- out(` recall: ${r.recall}`);
323
- out(` debt: ${r.debt}`);
324
- out(r.pass ? ' ✓ conforms to the Memory Contract.' : ' ✗ does not conform (see above).');
325
- process.exit(r.pass ? 0 : 1);
326
- }
327
270
  case '__hook': {
328
271
  try { execFileSync(process.execPath, [HOOK, ...rest], { stdio: 'inherit' }); } catch (e) { process.exit(e.status || 0); }
329
272
  break;
@@ -333,21 +276,18 @@ switch (cmd) {
333
276
  const top = gitTop(repoArg());
334
277
  if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
335
278
  const repo = top || repoArg();
336
- if (user) { wireClaude(repo, { user: true }); if (!dry) verifyAttach(repo); break; }
337
- if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); if (!dry) verifyAttach(repo); break; }
338
- wireGit(repo);
279
+ wireClaude(repo, { user });
339
280
  if (!dry) verifyAttach(repo);
340
281
  break;
341
282
  }
342
283
  case 'detach': {
343
284
  const purge = flags.has('--purge');
344
- if (flags.has('--all')) { detachAll(purge); break; }
345
285
  const repo = repoRoot();
346
286
  if (flags.has('--user')) unwireClaude(repo, { user: true });
347
287
  else if (flags.has('--claude')) unwireClaude(repo, {});
348
288
  else {
349
- // no flag: reverse whatever this repo's receipt says is attached (git and/or project Claude
350
- // hooks), so a bare `detach` is always the inverse of the `attach` you ran.
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.
351
291
  const a = readManifest(manifestPath(repo)).attachments || {};
352
292
  const hasClaude = !!(a.claude || a['claude-shared']);
353
293
  if (hasClaude) unwireClaude(repo, {});
@@ -357,9 +297,7 @@ switch (cmd) {
357
297
  if (flags.has('--verify')) verifyClean(repo);
358
298
  break;
359
299
  }
360
- case 'trace': { traceRepo(repoRoot()); break; }
361
- case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
362
- case 'where': { whereAttached(); break; }
300
+ case 'doctor': { doctorRepo(repoRoot()); break; }
363
301
  case 'rules': { out(rulesCli(rest)); break; }
364
302
  case 'shape': { out(shapeCli(rest)); break; }
365
303
  case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
@@ -370,22 +308,16 @@ switch (cmd) {
370
308
  if (!repo) process.stdout.write('🐴 sawbuck · no repo here');
371
309
  if (repo) {
372
310
  const name = basename(repo);
373
- let caught = 0;
374
- try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
311
+ const { caught, state, steer } = repoState(repo);
375
312
  // the last verdict from the activity feed (includes verified passes), so you see it working each turn
376
313
  let last = '';
377
314
  try { const L = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').trim().split('\n'); const e = JSON.parse(L[L.length - 1]); last = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' }[e.kind] || ''; } catch {}
378
- // one state model, shared with the Reins band: watching (wired in) / ready (checkable, not wired yet) / memory (history only)
379
- const active = existsSync(join(repo, '.sawbuck', 'state.json'));
380
- const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
381
- const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
382
- const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
315
+ const label = caught > 0 ? `${caught} caught` : state;
383
316
  const seg = label ? `🐴 sawbuck · ${label}` : '';
384
317
  const named = label ? `🐴 sawbuck · ${name} · ${label}` : ''; // which repo it detected, in the default line
385
318
  const base = named ? (last ? `${named} · ${last}` : named) : '';
386
319
  // live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
387
- let st = '';
388
- try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) { const p = Math.round(((sj.ema != null ? sj.ema : sj.steeringRate) || 0) * 100); st = `${p >= 50 ? '✊' : '🐎'} steer ${p}%`; } } catch {}
320
+ const st = steer ? `${steer.rate >= 50 ? '✊' : '🐎'} steer ${steer.rate}%` : '';
389
321
  // configurable template: SAWBUCK_STATUSLINE picks which segments show and in what order
390
322
  // (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
391
323
  // so you never get a dangling separator. Unset = the default composition. Mix in other
@@ -407,7 +339,24 @@ switch (cmd) {
407
339
  default: die(`unknown command '${cmd}'\n\n${help}`);
408
340
  }
409
341
 
410
- // --- the per-turn block: frame (forward, from contract.mjs) + context (backward) ---------------
342
+ function logReport(repo, rest) {
343
+ let lines = [];
344
+ try { lines = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').split('\n').filter(Boolean); } catch {}
345
+ if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); return; }
346
+ const ni = rest.indexOf('-n');
347
+ const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
348
+ const icon = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
349
+ const recent = lines.slice(-n).reverse();
350
+ out(`sawbuck activity (last ${recent.length}):`);
351
+ for (const l of recent) {
352
+ try {
353
+ const e = JSON.parse(l);
354
+ out(` ${(e.at || '').slice(0, 16).replace('T', ' ')} ${icon[e.kind] || '·'} ${String(e.kind).padEnd(11)} ${e.file || ''}${e.reason ? ' ' + e.reason : ''}`);
355
+ } catch {}
356
+ }
357
+ }
358
+
359
+ // --- context: the ranked history for the files in hand, on demand ---------------------------
411
360
  // the read face: ranked relevant history for what you're working on right now. Silent when there is
412
361
  // nothing to say, so it is safe on every UserPromptSubmit (that hook's stdout is injected into context;
413
362
  // "nothing notable" every turn would be noise). `speak` forces a reply for the explicit command.
@@ -456,67 +405,18 @@ function delFile(p) { try { if (existsSync(p)) unlinkSync(p); } catch {} }
456
405
  function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
457
406
 
458
407
  // --- .git/info/exclude: keep the worktree clean without touching .gitignore --
459
- // Rewrites a single marked block to the union of excludes the current
460
- // attachments need. Never deletes the file (an empty one is git's own default).
408
+ // Any attachment keeps the block, the last one out takes it. Returns whether the file changed.
461
409
  function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
462
- function syncExclude(repo, manifest) {
463
- const need = new Set();
464
- const a = manifest.attachments || {};
465
- if (Object.keys(a).length) for (const d of RECORD_DIRS) need.add(d); // any attach → the check may write the record dirs
466
- if (a.claude) need.add('.claude/settings.local.json'); // default claude settings are local; hide them too
467
- const lines = [...need];
468
- const f = excludeFile(repo);
469
- let body = (existsSync(f) ? readFileSync(f, 'utf8') : '').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
470
- if (lines.length) {
471
- if (body && !body.endsWith('\n')) body += '\n';
472
- body += `${S_START}\n${lines.join('\n')}\n${S_END}\n`;
473
- mkdirSync(dirname(f), { recursive: true });
474
- writeFileSync(f, body);
475
- } else if (existsSync(f)) {
476
- writeFileSync(f, body); // strip our block, leave the rest
477
- }
478
- }
410
+ function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
479
411
 
480
412
  // --- git post-commit --------------------------------------------------------
481
- // The hook prefers a `sawbuck` on PATH (so it survives a reinstall/upgrade) and
482
- // falls back to the absolute path of the sawbuck that attached (dev checkouts).
483
- function hookBlock(repo) {
484
- const r = JSON.stringify(repo);
485
- // git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check
486
- // shells out to git (worktrees, diffs) and would inherit them, operate on the wrong
487
- // repo, and silently catch nothing. Clear them so the reflex runs against `repo` clean.
488
- return `${S_START}\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_COMMON_DIR\nif command -v sawbuck >/dev/null 2>&1; then sawbuck __hook --repo ${r}; else ${nodeCmd()} ${JSON.stringify(SELF)} __hook --repo ${r}; fi || true\n${S_END}\n`;
489
- }
490
- function wireGit(repo) {
491
- if (!gitTop(repo)) usage(`${repo} is not a git repository`);
492
- const pc = gitPath(repo, 'hooks/post-commit');
493
- const existed = existsSync(pc);
494
- let body = existed ? readFileSync(pc, 'utf8') : '#!/bin/sh\n';
495
- if (body.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
496
- if (!body.startsWith('#!')) body = '#!/bin/sh\n' + body;
497
- if (!body.endsWith('\n')) body += '\n';
498
- if (dry) {
499
- out(dryText([
500
- `${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
501
- `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')})`,
502
- `create ${manifestPath(repo)} (attach receipt)`,
503
- ]));
504
- return;
505
- }
506
- writeFileSync(pc, body + hookBlock(repo));
507
- chmodSync(pc, 0o755);
508
- const m = readManifest(manifestPath(repo));
509
- m.attachments = { ...m.attachments, git: { pc: { created: !existed } } };
510
- syncExclude(repo, m);
511
- writeManifest(manifestPath(repo), m);
512
- regAdd(repo, 'git');
513
- out(`sawbuck: attached (git post-commit) in ${repo}`);
514
- }
413
+ // Older versions could attach here. Nothing wires it now; detach still takes it out.
515
414
  function unwireGit(repo) {
516
415
  const pc = gitPath(repo, 'hooks/post-commit');
517
416
  const m = readManifest(manifestPath(repo));
518
417
  const hasReceipt = !!m.attachments?.git;
519
- if (!existsSync(pc) && !hasReceipt) { out('sawbuck: nothing to detach'); return; }
418
+ // 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; }
520
420
  if (existsSync(pc)) {
521
421
  const stripped = readFileSync(pc, 'utf8').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
522
422
  const created = m.attachments?.git?.pc?.created;
@@ -525,7 +425,6 @@ function unwireGit(repo) {
525
425
  else writeFileSync(pc, stripped);
526
426
  }
527
427
  if (hasReceipt) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
528
- regRemove(repo, 'git');
529
428
  out(`sawbuck: detached (git) from ${repo}`);
530
429
  }
531
430
  function isTrivialHook(text) { return text.replace(/^#!.*\n?/, '').trim() === ''; }
@@ -547,10 +446,10 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
547
446
  return `"$(command -v node || echo ${JSON.stringify(execPath).replace(/^"|"$/g, "'")})"`;
548
447
  }
549
448
  function wireClaude(repo, opts) {
550
- const { user, shared } = opts;
449
+ const { user } = opts;
551
450
  // user-level hooks already cover every repo; a second set would run each check twice and spend the
552
451
  // send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
553
- if (!user && !shared) {
452
+ if (!user) {
554
453
  const uf = claudeFile(repo, { user: true }).f;
555
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 {}
556
455
  }
@@ -578,7 +477,7 @@ function wireClaude(repo, opts) {
578
477
  if (dry) {
579
478
  out(dryText([
580
479
  `${upgrading ? 'update' : fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
581
- user ? null : `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')}${shared ? '' : ', .claude/settings.local.json'})`,
480
+ user ? null : `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
582
481
  `create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
583
482
  ].filter(Boolean)));
584
483
  return;
@@ -589,8 +488,8 @@ function wireClaude(repo, opts) {
589
488
  cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
590
489
  };
591
490
  put('UserPromptSubmit', ctx);
592
- // right after a compaction the summary has just dropped detail: re-impress the contract frame and the
593
- // relevant history there too (SessionStart with the `compact` source injects stdout into context).
491
+ // right after a compaction the summary has just dropped detail: re-impress the contract frame there too
492
+ // (SessionStart with the `compact` source injects stdout into context).
594
493
  put('SessionStart', ctx, 'compact');
595
494
  put('Stop', chk);
596
495
  mkdirSync(dir, { recursive: true });
@@ -610,19 +509,17 @@ function wireClaude(repo, opts) {
610
509
  }
611
510
  } catch {}
612
511
  }
613
- const mode = user ? 'user' : shared ? 'claude-shared' : 'claude';
512
+ const mode = user ? 'user' : 'claude';
614
513
  const mp = user ? userManifestPath() : manifestPath(repo);
615
514
  const m = readManifest(mp);
616
515
  const prev = (m.attachments || {})[mode]; // an upgrade keeps the receipt of what the first attach created
617
516
  m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: prev ? !!prev.fileCreated : !fileExisted, dirCreated: prev ? !!prev.dirCreated : !dirExisted } };
618
517
  if (!user) syncExclude(repo, m);
619
518
  writeManifest(mp, m);
620
- regAdd(repo, mode);
621
- out(upgrading ? `sawbuck: updated the ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f} to this version`
622
- : `sawbuck: wired ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f}`);
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}`);
623
521
  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.');
624
522
  if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
625
- else if (!shared) out(' (settings.local.json is gitignored; use --shared to commit the hooks for your team)');
626
523
  }
627
524
  function unwireClaude(repo, { user }) {
628
525
  const mp = user ? userManifestPath() : manifestPath(repo);
@@ -648,7 +545,6 @@ function unwireClaude(repo, { user }) {
648
545
  if (rec) { delete m.attachments[mode]; touched = true; }
649
546
  }
650
547
  if (touched) { if (!user) syncExclude(repo, m); writeManifest(mp, m); }
651
- for (const mode of modes) regRemove(repo, mode);
652
548
  if (!did && !touched) out('sawbuck: no Claude attachment found to detach');
653
549
  }
654
550
  function removeClaudeEntries(cfg) {
@@ -677,13 +573,6 @@ function footprint(repo) {
677
573
  if (existsSync(mp)) { const m = readManifest(mp); items.push({ what: mp, state: `receipt: ${Object.keys(m.attachments || {}).join(', ') || 'empty'}` }); }
678
574
  return items;
679
575
  }
680
- function traceRepo(repo) {
681
- const items = footprint(repo);
682
- if (!items.length) { out(`sawbuck: no footprint in ${repo}, nothing attached.`); return; }
683
- out(`sawbuck footprint in ${repo}:`);
684
- for (const it of items) out(` ${it.state.padEnd(22)} ${it.what}`);
685
- out('\n detach reverses every line above; `detach --verify` proves it.');
686
- }
687
576
  function verifyClean(repo) {
688
577
  // the record dirs may stay (your data, unless --purge); the mechanism must be gone.
689
578
  const left = footprint(repo).filter((it) => !/record dir/.test(it.state));
@@ -728,41 +617,5 @@ function doctorRepo(repo) {
728
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.');
729
618
  if (bad) process.exitCode = 1;
730
619
  }
731
- function doctorAll() {
732
- const r = regRead();
733
- if (!r.repos.length) { out('sawbuck: nothing to check (registry empty).'); return; }
734
- const repos = [...new Set(r.repos.map((x) => x.repo))];
735
- for (const repo of repos) doctorRepo(repo);
736
- }
737
-
738
- // --- registry: know and remove every attachment -----------------------------
739
- function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
740
- function regRead() { 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
741
- function regWrite(r) {
742
- const f = regPath();
743
- if (!r.repos.length) { delFile(f); rmIfEmptyDir(dirname(f)); return; }
744
- mkdirSync(dirname(f), { recursive: true });
745
- writeFileSync(f, JSON.stringify(r, null, 2) + '\n');
746
- }
747
- 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); }
748
- function regRemove(repo, mode) { const r = regRead(); r.repos = r.repos.filter((x) => !(x.repo === repo && x.mode === mode)); regWrite(r); }
749
- function whereAttached() {
750
- const r = regRead();
751
- if (!r.repos.length) { out('sawbuck: not attached anywhere (registry empty).'); return; }
752
- out('sawbuck is attached to:');
753
- for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.repo}`);
754
- }
755
- function detachAll(purge) {
756
- const r = regRead();
757
- if (!r.repos.length) { out('sawbuck: nothing to detach (registry empty).'); return; }
758
- for (const x of [...r.repos]) {
759
- if (x.mode === 'git') unwireGit(x.repo);
760
- else if (x.mode === 'user') unwireClaude(x.repo, { user: true });
761
- else unwireClaude(x.repo, {});
762
- if (purge) purgeRecord(x.repo);
763
- }
764
- out('sawbuck: detached everywhere.');
765
- }
766
-
767
620
  // --- shared helpers ---------------------------------------------------------
768
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'); }