sawbuck 0.1.44 → 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,16 +1,10 @@
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
10
  import { erasedBlock, erasedCommits } from './rewrites.mjs';
@@ -23,7 +17,6 @@ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSy
23
17
  import { basename, join, dirname, resolve } from 'node:path';
24
18
  import { fileURLToPath } from 'node:url';
25
19
  import { homedir } from 'node:os';
26
- import { recall as memRecall, conform as memConform } from './memory.mjs';
27
20
  import { PW } from './bins.mjs';
28
21
  import { frameBlock, mandatesInForce, protectedDirty, blobAt, lockState } from './contract.mjs';
29
22
 
@@ -54,7 +47,7 @@ const argv = process.argv.slice(2);
54
47
  const cmd = argv[0];
55
48
  const rest = argv.slice(1);
56
49
  const flags = new Set(rest.filter((a) => a.startsWith('--')));
57
- // `-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.
58
51
  const positional = rest.filter((a, i) => !a.startsWith('--') && a !== '-n' && rest[i - 1] !== '-n');
59
52
  const repoArg = () => resolve(positional[0] || '.');
60
53
  const dry = flags.has('--dry-run');
@@ -62,7 +55,7 @@ const dry = flags.has('--dry-run');
62
55
  let version = '0.0.0';
63
56
  try { version = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).version; } catch {}
64
57
 
65
- 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
66
59
 
67
60
  Usage:
68
61
  sawbuck check [repo] [--base R --head R] [--repeat N] [--allow "<reason>"] [--json]
@@ -71,29 +64,22 @@ Usage:
71
64
  fastest way to see it work. Every promptwheel run flag passes through.
72
65
  Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
73
66
  sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
74
- Customize the contract (promptwheel.config.json), with notes from what your
75
- 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
76
69
  (--no-agent-block to skip). Wizard in a terminal, non-interactive in a pipe/CI (or --yes).
77
- sawbuck attach --claude Check every Claude Code turn in this repo. --dry-run prints the footprint and writes nothing
78
- Also: no flag for a git post-commit hook, --claude --shared for a tracked settings file,
79
- --user for every repo
80
- 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]
81
72
  Remove it and restore the repo (no flag: whatever is attached here); --verify
82
73
  proves it came back clean; --purge also deletes the record (.sawbuck/)
83
74
  sawbuck status [repo] The inventory: caught over time + debt accruing now + whether it is armed. --json
84
- sawbuck log [repo] [-n N] Recent checks, newest first (verified / gamed / regression)
75
+ --log [-n N] for the recent checks, newest first
85
76
  sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
86
77
  The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
87
78
  shows only what a branch adds; --fail-on exits nonzero (a CI gate)
88
79
  sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
89
- sawbuck frame [repo] The contract's protected paths, inherited ones included, as one compact block;
90
- silent without a contract (--speak explains)
91
- sawbuck turn [repo] What attach --claude runs on a prompt: the frame, once a session and after a compaction
92
-
93
- sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
94
- sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
95
- sawbuck where List every repo Sawbuck is attached to
96
- 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
97
83
  sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
98
84
  sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
99
85
  sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
@@ -119,9 +105,9 @@ const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
119
105
 
120
106
  if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
121
107
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
122
- 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
123
109
 
124
- sawbuck contract sign this repo: protected paths, your tests, CI
110
+ sawbuck contract give this repo its rules: protected paths, your tests
125
111
  sawbuck check verify your latest change right now
126
112
  sawbuck status what it has caught here
127
113
 
@@ -132,8 +118,8 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
132
118
  switch (cmd) {
133
119
  case 'contract': {
134
120
  const top = gitTop(repoArg());
135
- if (!top) usage(`${repoArg()} is not a git repository (a contract signs one; run it inside your project)`);
136
- 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' }); }
137
123
  catch (e) { process.exit(e.status || 1); }
138
124
  break;
139
125
  }
@@ -144,7 +130,8 @@ switch (cmd) {
144
130
  }
145
131
  case 'status': {
146
132
  // the inventory: one view unioning what the check has caught over time, what debt is accruing
147
- // 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; }
148
135
  try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoArg(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
149
136
  catch (e) { process.exit(e.status || 1); }
150
137
  break;
@@ -187,7 +174,7 @@ switch (cmd) {
187
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).`);
188
175
  else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
189
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.');
190
- 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.');
191
178
  else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
192
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.');
193
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.');
@@ -195,25 +182,6 @@ switch (cmd) {
195
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.`);
196
183
  process.exit(code);
197
184
  }
198
- case 'log': {
199
- // the event feed: a running log of what the check did, so you can watch it work.
200
- const repo = repoArg();
201
- let lines = [];
202
- try { lines = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').split('\n').filter(Boolean); } catch {}
203
- if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
204
- const ni = rest.indexOf('-n');
205
- const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
206
- const icon = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
207
- const recent = lines.slice(-n).reverse();
208
- out(`sawbuck activity (last ${recent.length}):`);
209
- for (const l of recent) {
210
- try {
211
- const e = JSON.parse(l);
212
- out(` ${(e.at || '').slice(0, 16).replace('T', ' ')} ${icon[e.kind] || '·'} ${String(e.kind).padEnd(11)} ${e.file || ''}${e.reason ? ' ' + e.reason : ''}`);
213
- } catch {}
214
- }
215
- break;
216
- }
217
185
  case 'rank': {
218
186
  try { execFileSync(process.execPath, [join(HERE, 'rank.mjs'), ...rest], { stdio: 'inherit' }); }
219
187
  catch (e) { process.exit(e.status || 1); }
@@ -239,7 +207,7 @@ switch (cmd) {
239
207
  // the ONE per-prompt hook. The agent gets the contract frame once a session and again after a compaction: one
240
208
  // written statement held in every bench, so saying it on each prompt only spends context. The relevant history
241
209
  // is on demand (`sawbuck context`), and per turn only with {"turn":{"context":true}} in sawbuck.config.json.
242
- // Wired by `attach --claude` on UserPromptSubmit and after a compaction.
210
+ // Wired by `attach` on UserPromptSubmit and after a compaction.
243
211
  // the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
244
212
  // subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
245
213
  const top = gitTop(repoArg());
@@ -299,32 +267,6 @@ switch (cmd) {
299
267
  if (o) out(o);
300
268
  break;
301
269
  }
302
- case 'recall': {
303
- // the notes face: supplementary relevant notes for your current changes, from any non-git memory
304
- // provider (e.g. an Obsidian vault). On-demand. Git history + live debt come from context / debt.
305
- const repo = repoRoot();
306
- const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
307
- let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
308
- if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
309
- if (!changed.length) { out('🐴 sawbuck: nothing changed to look up.'); break; }
310
- const notes = memRecall(repo, changed);
311
- 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; }
312
- out(`🐴 related notes for ${changed.length} changed path${changed.length === 1 ? '' : 's'} (leads, not conclusions):`);
313
- for (const n of notes) out(` - [${n.source}] ${n.summary}${n.ref ? ` (${n.ref})` : ''}`);
314
- break;
315
- }
316
- case 'memory-conformance': {
317
- // check that a provider bin speaks the Memory Contract (recall returns Finding[]; debt returns
318
- // Suppression[] or exits nonzero to signal it is not a git-truth provider). Read-only probe.
319
- const bin = positional[0];
320
- if (!bin) die('usage: sawbuck memory-conformance <provider-bin> (a bin speaking the contract verbs recall/debt/record)');
321
- const r = memConform(bin, process.cwd());
322
- out(`🐴 memory-conformance: ${bin}`);
323
- out(` recall: ${r.recall}`);
324
- out(` debt: ${r.debt}`);
325
- out(r.pass ? ' ✓ conforms to the Memory Contract.' : ' ✗ does not conform (see above).');
326
- process.exit(r.pass ? 0 : 1);
327
- }
328
270
  case '__hook': {
329
271
  try { execFileSync(process.execPath, [HOOK, ...rest], { stdio: 'inherit' }); } catch (e) { process.exit(e.status || 0); }
330
272
  break;
@@ -334,21 +276,18 @@ switch (cmd) {
334
276
  const top = gitTop(repoArg());
335
277
  if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
336
278
  const repo = top || repoArg();
337
- if (user) { wireClaude(repo, { user: true }); if (!dry) verifyAttach(repo); break; }
338
- if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); if (!dry) verifyAttach(repo); break; }
339
- wireGit(repo);
279
+ wireClaude(repo, { user });
340
280
  if (!dry) verifyAttach(repo);
341
281
  break;
342
282
  }
343
283
  case 'detach': {
344
284
  const purge = flags.has('--purge');
345
- if (flags.has('--all')) { detachAll(purge); break; }
346
285
  const repo = repoRoot();
347
286
  if (flags.has('--user')) unwireClaude(repo, { user: true });
348
287
  else if (flags.has('--claude')) unwireClaude(repo, {});
349
288
  else {
350
- // no flag: reverse whatever this repo's receipt says is attached (git and/or project Claude
351
- // 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.
352
291
  const a = readManifest(manifestPath(repo)).attachments || {};
353
292
  const hasClaude = !!(a.claude || a['claude-shared']);
354
293
  if (hasClaude) unwireClaude(repo, {});
@@ -358,9 +297,7 @@ switch (cmd) {
358
297
  if (flags.has('--verify')) verifyClean(repo);
359
298
  break;
360
299
  }
361
- case 'trace': { traceRepo(repoRoot()); break; }
362
- case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
363
- case 'where': { whereAttached(); break; }
300
+ case 'doctor': { doctorRepo(repoRoot()); break; }
364
301
  case 'rules': { out(rulesCli(rest)); break; }
365
302
  case 'shape': { out(shapeCli(rest)); break; }
366
303
  case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
@@ -402,6 +339,23 @@ switch (cmd) {
402
339
  default: die(`unknown command '${cmd}'\n\n${help}`);
403
340
  }
404
341
 
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
+
405
359
  // --- context: the ranked history for the files in hand, on demand ---------------------------
406
360
  // the read face: ranked relevant history for what you're working on right now. Silent when there is
407
361
  // nothing to say, so it is safe on every UserPromptSubmit (that hook's stdout is injected into context;
@@ -456,40 +410,7 @@ function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
456
410
  function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
457
411
 
458
412
  // --- git post-commit --------------------------------------------------------
459
- // The hook prefers a `sawbuck` on PATH (so it survives a reinstall/upgrade) and
460
- // falls back to the absolute path of the sawbuck that attached (dev checkouts).
461
- function hookBlock(repo) {
462
- const r = JSON.stringify(repo);
463
- // git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check
464
- // shells out to git (worktrees, diffs) and would inherit them, operate on the wrong
465
- // repo, and silently catch nothing. Clear them so the reflex runs against `repo` clean.
466
- 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`;
467
- }
468
- function wireGit(repo) {
469
- if (!gitTop(repo)) usage(`${repo} is not a git repository`);
470
- const pc = gitPath(repo, 'hooks/post-commit');
471
- const existed = existsSync(pc);
472
- let body = existed ? readFileSync(pc, 'utf8') : '#!/bin/sh\n';
473
- if (body.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
474
- if (!body.startsWith('#!')) body = '#!/bin/sh\n' + body;
475
- if (!body.endsWith('\n')) body += '\n';
476
- if (dry) {
477
- out(dryText([
478
- `${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
479
- `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
480
- `create ${manifestPath(repo)} (attach receipt)`,
481
- ]));
482
- return;
483
- }
484
- writeFileSync(pc, body + hookBlock(repo));
485
- chmodSync(pc, 0o755);
486
- const m = readManifest(manifestPath(repo));
487
- m.attachments = { ...m.attachments, git: { pc: { created: !existed } } };
488
- syncExclude(repo, m);
489
- writeManifest(manifestPath(repo), m);
490
- regAdd(repo, 'git');
491
- out(`sawbuck: attached (git post-commit) in ${repo}`);
492
- }
413
+ // Older versions could attach here. Nothing wires it now; detach still takes it out.
493
414
  function unwireGit(repo) {
494
415
  const pc = gitPath(repo, 'hooks/post-commit');
495
416
  const m = readManifest(manifestPath(repo));
@@ -504,7 +425,6 @@ function unwireGit(repo) {
504
425
  else writeFileSync(pc, stripped);
505
426
  }
506
427
  if (hasReceipt) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
507
- regRemove(repo, 'git');
508
428
  out(`sawbuck: detached (git) from ${repo}`);
509
429
  }
510
430
  function isTrivialHook(text) { return text.replace(/^#!.*\n?/, '').trim() === ''; }
@@ -526,10 +446,10 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
526
446
  return `"$(command -v node || echo ${JSON.stringify(execPath).replace(/^"|"$/g, "'")})"`;
527
447
  }
528
448
  function wireClaude(repo, opts) {
529
- const { user, shared } = opts;
449
+ const { user } = opts;
530
450
  // user-level hooks already cover every repo; a second set would run each check twice and spend the
531
451
  // send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
532
- if (!user && !shared) {
452
+ if (!user) {
533
453
  const uf = claudeFile(repo, { user: true }).f;
534
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 {}
535
455
  }
@@ -589,19 +509,17 @@ function wireClaude(repo, opts) {
589
509
  }
590
510
  } catch {}
591
511
  }
592
- const mode = user ? 'user' : shared ? 'claude-shared' : 'claude';
512
+ const mode = user ? 'user' : 'claude';
593
513
  const mp = user ? userManifestPath() : manifestPath(repo);
594
514
  const m = readManifest(mp);
595
515
  const prev = (m.attachments || {})[mode]; // an upgrade keeps the receipt of what the first attach created
596
516
  m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: prev ? !!prev.fileCreated : !fileExisted, dirCreated: prev ? !!prev.dirCreated : !dirExisted } };
597
517
  if (!user) syncExclude(repo, m);
598
518
  writeManifest(mp, m);
599
- regAdd(repo, mode);
600
- out(upgrading ? `sawbuck: updated the ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f} to this version`
601
- : `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}`);
602
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.');
603
522
  if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
604
- else if (!shared) out(' (settings.local.json is gitignored; use --shared to commit the hooks for your team)');
605
523
  }
606
524
  function unwireClaude(repo, { user }) {
607
525
  const mp = user ? userManifestPath() : manifestPath(repo);
@@ -627,7 +545,6 @@ function unwireClaude(repo, { user }) {
627
545
  if (rec) { delete m.attachments[mode]; touched = true; }
628
546
  }
629
547
  if (touched) { if (!user) syncExclude(repo, m); writeManifest(mp, m); }
630
- for (const mode of modes) regRemove(repo, mode);
631
548
  if (!did && !touched) out('sawbuck: no Claude attachment found to detach');
632
549
  }
633
550
  function removeClaudeEntries(cfg) {
@@ -656,13 +573,6 @@ function footprint(repo) {
656
573
  if (existsSync(mp)) { const m = readManifest(mp); items.push({ what: mp, state: `receipt: ${Object.keys(m.attachments || {}).join(', ') || 'empty'}` }); }
657
574
  return items;
658
575
  }
659
- function traceRepo(repo) {
660
- const items = footprint(repo);
661
- if (!items.length) { out(`sawbuck: no footprint in ${repo}, nothing attached.`); return; }
662
- out(`sawbuck footprint in ${repo}:`);
663
- for (const it of items) out(` ${it.state.padEnd(22)} ${it.what}`);
664
- out('\n detach reverses every line above; `detach --verify` proves it.');
665
- }
666
576
  function verifyClean(repo) {
667
577
  // the record dirs may stay (your data, unless --purge); the mechanism must be gone.
668
578
  const left = footprint(repo).filter((it) => !/record dir/.test(it.state));
@@ -707,41 +617,5 @@ function doctorRepo(repo) {
707
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.');
708
618
  if (bad) process.exitCode = 1;
709
619
  }
710
- function doctorAll() {
711
- const r = regRead();
712
- if (!r.repos.length) { out('sawbuck: nothing to check (registry empty).'); return; }
713
- const repos = [...new Set(r.repos.map((x) => x.repo))];
714
- for (const repo of repos) doctorRepo(repo);
715
- }
716
-
717
- // --- registry: know and remove every attachment -----------------------------
718
- function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
719
- 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
720
- function regWrite(r) {
721
- const f = regPath();
722
- if (!r.repos.length) { delFile(f); rmIfEmptyDir(dirname(f)); return; }
723
- mkdirSync(dirname(f), { recursive: true });
724
- writeFileSync(f, JSON.stringify(r, null, 2) + '\n');
725
- }
726
- 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); }
727
- function regRemove(repo, mode) { const r = regRead(); r.repos = r.repos.filter((x) => !(x.repo === repo && x.mode === mode)); regWrite(r); }
728
- function whereAttached() {
729
- const r = regRead();
730
- if (!r.repos.length) { out('sawbuck: not attached anywhere (registry empty).'); return; }
731
- out('sawbuck is attached to:');
732
- for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.repo}`);
733
- }
734
- function detachAll(purge) {
735
- const r = regRead();
736
- if (!r.repos.length) { out('sawbuck: nothing to detach (registry empty).'); return; }
737
- for (const x of [...r.repos]) {
738
- if (x.mode === 'git') unwireGit(x.repo);
739
- else if (x.mode === 'user') unwireClaude(x.repo, { user: true });
740
- else unwireClaude(x.repo, {});
741
- if (purge) purgeRecord(x.repo);
742
- }
743
- out('sawbuck: detached everywhere.');
744
- }
745
-
746
620
  // --- shared helpers ---------------------------------------------------------
747
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'); }
package/shape.mjs CHANGED
@@ -1,18 +1,17 @@
1
- // Your steering shape: what you push on, as 12 spokes in 4 groups. The model labels a sample of your messages; only the
2
- // totals are kept. Two models agree on single messages about half the time and on the shape at 0.93, so a single label is
3
- // never shown. It says what you pushed on, not how good you are. Each run is saved with its date to compare later.
1
+ // Your steering shape: what you push on, as 11 spokes in 4 groups. The model labels a sample of your messages; only the
2
+ // totals are kept. Three models agreed on single messages about 70% of the time and on the shape at 0.87 or better, so a
3
+ // single label is never shown. It says what you pushed on, not how good you are. Each run is saved with its date.
4
4
  import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmSync, copyFileSync } from 'node:fs';
5
5
  import { execFileSync } from 'node:child_process';
6
6
  import { join, dirname } from 'node:path';
7
7
  import { homedir } from 'node:os';
8
8
  import { prepareRules } from './rules.mjs';
9
- import { exposure, writtenRules } from './exposure.mjs';
10
9
 
11
- export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['direction', 'scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
10
+ export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
12
11
  const SPOKES = Object.values(GROUPS).flat();
13
12
  const MIN_SPOKES = 200; // below this, spokes are noise: show the 4 groups
14
13
  const SAMPLE = 600;
15
- const paths = (home) => ({ dir: join(home, '.sawbuck'), rules: join(home, '.sawbuck', 'rule-input.txt'), input: join(home, '.sawbuck', 'shape-input.txt'), hist: join(home, '.sawbuck', 'shape-history.jsonl'), expo: join(home, '.sawbuck', 'shape-exposure.json'), svg: join(home, '.sawbuck', 'shape.svg') });
14
+ const paths = (home) => ({ dir: join(home, '.sawbuck'), rules: join(home, '.sawbuck', 'rule-input.txt'), input: join(home, '.sawbuck', 'shape-input.txt'), hist: join(home, '.sawbuck', 'shape-history.jsonl'), svg: join(home, '.sawbuck', 'shape.svg') });
16
15
  const title = (s) => s[0].toUpperCase() + s.slice(1);
17
16
 
18
17
  export function prepareShape({ home = homedir() } = {}) {
@@ -22,14 +21,14 @@ export function prepareShape({ home = homedir() } = {}) {
22
21
  for (let i = lines.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [lines[i], lines[j]] = [lines[j], lines[i]]; }
23
22
  const pick = lines.slice(0, SAMPLE);
24
23
  writeFileSync(p.input, pick.map((l, i) => `${i + 1}\t${l}`).join('\n') + '\n', { mode: 0o600 });
25
- writeFileSync(p.expo, JSON.stringify({ ...exposure({ root: join(home, '.claude', 'projects') }), written: writtenRules({ home }) }) + '\n');
26
24
  return { input: p.input, lines: pick.length, secrets: r.secrets };
27
25
  }
28
26
 
29
27
  export function tally(labels) {
30
28
  const counts = Object.fromEntries(SPOKES.map((s) => [s, 0]));
31
29
  let none = 0;
32
- for (const v of Object.values(labels)) { if (v === 'none') none++; else if (v in counts) counts[v]++; }
30
+ // two models split scope and direction at random (shape r 0.55 with both, 0.97 merged), so they count as one
31
+ for (const raw of Object.values(labels)) { const v = raw === 'direction' ? 'scope' : raw; if (v === 'none') none++; else if (v in counts) counts[v]++; }
33
32
  const total = SPOKES.reduce((a, s) => a + counts[s], 0);
34
33
  const groups = Object.fromEntries(Object.entries(GROUPS).map(([g, ss]) => [g, total ? Math.round(100 * ss.reduce((a, s) => a + counts[s], 0) / total) : 0]));
35
34
  const top = Math.max(1, ...SPOKES.map((s) => counts[s]));
@@ -40,41 +39,13 @@ export function tally(labels) {
40
39
  }
41
40
 
42
41
  // What was not said: each spoke we can measure from what the agent did, read against how often you push on it.
43
- const MIN_TURNS = 30; // a pattern needs enough turns behind it: 1 of 3 is not one
44
- const HIGH_PUSH = 8, HIGH_DOING = 10, HIGH_FAULT = 5; // a fault counts sooner than plain activity
45
- const SEEN = {
46
- proof: { fault: true, of: 'code', n: 'unverified', says: 'changed code and ran no check within 3 turns', rule: 'Run the tests before saying a change works.' },
47
- safety: { fault: true, of: 'working', n: 'risky', says: 'touched something risky', rule: 'Ask before force pushing, deleting outside temp folders, or touching secrets, workflows or deploy files.' },
48
- process: { of: 'working', n: 'shipped', says: 'committed, pushed or published', rule: 'Do not commit or push unless I ask.' },
49
- voice: { of: 'working', n: 'copy', says: 'wrote docs or copy', rule: 'Write docs in plain, short sentences.' },
50
- visual: { of: 'working', n: 'ui', says: 'changed the interface', rule: 'Show me interface changes before calling them done.' },
51
- simplicity: { fault: true, of: 'working', n: 'big', says: 'added 200 lines or more', rule: 'Keep changes small. Ask before adding more than 200 lines.' },
52
- naming: { of: 'working', n: 'renames', says: 'renamed something', rule: 'Ask before renaming anything public.' },
53
- autonomy: { of: 'working', n: 'asked', says: 'stopped to ask you', rule: 'Keep going on reversible work without asking.' },
54
- };
55
- export function patterns(t, c) {
56
- if (!c || !c.working) return [];
57
- return Object.entries(SEEN).map(([s, m]) => {
58
- const pct = Math.round(100 * (c[m.n] || 0) / Math.max(1, c[m.of]));
59
- const push = t.shares[s], often = pct >= (m.fault ? HIGH_FAULT : HIGH_DOING), pushed = push >= HIGH_PUSH;
60
- // a fault you keep raising is a fight; activity you raise is just attention, since committing is not a failure
61
- const has = !!(c.written && c.written[s]);
62
- const enough = c[m.of] >= MIN_TURNS;
63
- const kind = !enough ? 'too few' : often && pushed ? (m.fault ? 'fighting it' : 'watched') : often ? (has ? 'covered' : 'blind spot') : pushed ? 'handled' : 'quiet';
64
- return { spoke: s, kind, pct, count: c[m.n] || 0, of: c[m.of], push, says: m.says, rule: m.rule, has };
65
- });
66
- }
67
-
68
- export function shapeSvg(t, found = []) {
42
+ export function shapeSvg(t) {
69
43
  const names = t.spokes ? SPOKES : Object.keys(GROUPS);
70
44
  const vals = t.spokes ? names.map((s) => t.ratings[s] / 99) : names.map((g) => 0.25 + 0.75 * t.groups[g] / Math.max(1, ...Object.values(t.groups)));
71
45
  const pt = (i, v) => { const a = 2 * Math.PI * i / names.length; return [(500 + 330 * v * Math.sin(a)).toFixed(1), (520 - 330 * v * Math.cos(a)).toFixed(1)]; };
72
- const doing = Object.fromEntries(found.map((f) => [f.spoke, f.pct]));
73
- const topDoing = Math.max(1, ...Object.values(doing));
74
- const seenPts = t.spokes && found.length ? names.flatMap((n, i) => (n in doing ? [pt(i, 0.4 + 0.59 * Math.sqrt(doing[n] / topDoing))] : [])) : [];
75
46
  const ring = (v) => names.map((_, i) => pt(i, v).join(',')).join(' ');
76
47
  const labels = names.map((n, i) => { const [x, y] = pt(i, 1.14); return `<text x="${x}" y="${y}" text-anchor="middle" fill="#e8e6e1" font-size="22" font-weight="700">${title(n)}</text><text x="${x}" y="${+y + 28}" text-anchor="middle" fill="#c9a24a" font-size="24" font-weight="700">${t.spokes ? t.ratings[n] : t.groups[n] + '%'}</text>`; }).join('');
77
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" font-family="system-ui,sans-serif"><rect width="1000" height="1000" fill="#0b0e13"/><text x="50" y="70" fill="#e8e6e1" font-size="40" font-weight="700">My steering shape</text>${[0.25, 0.5, 0.75, 1].map((v) => `<polygon points="${ring(v)}" fill="none" stroke="#2a313b"/>`).join('')}<polygon points="${names.map((_, i) => pt(i, vals[i]).join(',')).join(' ')}" fill="#c9a24a" fill-opacity="0.35" stroke="#c9a24a" stroke-width="4"/>${seenPts.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="9" fill="#4fb3a5"/>`).join('')}${labels}<text x="50" y="960" fill="#8d94a0" font-size="19">Gold: what I push on, from ${t.n} messages.${seenPts.length ? ' Teal dots: what the agent did.' : ''} Relative to my own top. Not a score. sawbuck.ai</text></svg>\n`;
48
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" font-family="system-ui,sans-serif"><rect width="1000" height="1000" fill="#0b0e13"/><text x="50" y="70" fill="#e8e6e1" font-size="40" font-weight="700">My steering shape</text>${[0.25, 0.5, 0.75, 1].map((v) => `<polygon points="${ring(v)}" fill="none" stroke="#2a313b"/>`).join('')}<polygon points="${names.map((_, i) => pt(i, vals[i]).join(',')).join(' ')}" fill="#c9a24a" fill-opacity="0.35" stroke="#c9a24a" stroke-width="4"/>${labels}<text x="50" y="960" fill="#8d94a0" font-size="19">Gold: what I push on, from ${t.n} messages. Relative to my own top. Not a score. sawbuck.ai</text></svg>\n`;
78
49
  }
79
50
 
80
51
  // A PNG of the card, with what is already on the machine: sawbuck ships no image library. A snap browser sees neither
@@ -109,25 +80,15 @@ export function shapeCli(args, { home = homedir() } = {}) {
109
80
  mkdirSync(p.dir, { recursive: true });
110
81
  let prev = null;
111
82
  if (existsSync(p.hist)) { const l = readFileSync(p.hist, 'utf8').trim().split('\n').filter(Boolean); if (l.length) prev = JSON.parse(l[l.length - 1]); }
112
- let c = null; try { c = JSON.parse(readFileSync(p.expo, 'utf8')); } catch {}
113
- const found = patterns(t, c);
114
- writeFileSync(p.svg, shapeSvg(t, found));
83
+ writeFileSync(p.svg, shapeSvg(t));
115
84
  const out = [`🐴 your steering shape, from ${t.n} messages that pushed on something (${t.none} did not). What you push on, not a score.`, ''];
116
85
  for (const [g, ss] of Object.entries(GROUPS)) {
117
86
  const was = prev && prev.groups[g] != null ? ` (was ${prev.groups[g]}%)` : '';
118
87
  out.push(` ${g.padEnd(8)} ${String(t.groups[g]).padStart(3)}%${was}`);
119
88
  if (t.spokes) out.push(` ${ss.map((s) => `${title(s)} ${t.ratings[s]}`).join(' ')}`);
120
89
  }
121
- if (!t.spokes) out.push('', ` Too few messages for the 12 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
122
- appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares, ...(c ? { exposure: c } : {}) }) + '\n');
123
- const fight = found.filter((f) => f.kind === 'fighting it'), blind = found.filter((f) => f.kind === 'blind spot');
124
- if (fight.length || blind.length) out.push('', ' Patterns to work on:');
125
- for (const f of fight) out.push(` Fighting it: ${title(f.spoke)}. You push on it (${f.push}%), and the agent still ${f.says} in ${f.pct}% of turns (${f.count} of ${f.of}).`, f.has ? ' You seem to have a written rule on this already, and it is not holding. Make it specific, or protect it in the contract.' : ` Try: ${f.rule}`);
126
- for (const f of blind) out.push(` Not said: ${title(f.spoke)}. The agent ${f.says} in ${f.pct}% of turns (${f.count} of ${f.of}), and only ${f.push}% of your pushes touch it. Is that what you want?`, ` Try: ${f.rule}`);
127
- const covered = found.filter((f) => f.kind === 'covered');
128
- if (covered.length) out.push('', ` Written down already, so left out: ${covered.map((f) => title(f.spoke)).join(', ')}. Matched by keyword, so check they say what you mean.`);
129
- if (found.some((f) => f.kind === 'too few')) out.push('', ` Not enough recent turns to name every pattern yet: each needs ${MIN_TURNS}.`);
130
- if (found.length) out.push('', ' What the agent did covers your last 90 days, not counting scratch work. Direction, scope, correctness and honesty have no behaviour signal, so they show only what you said.');
90
+ if (!t.spokes) out.push('', ` Too few messages for the 11 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
91
+ appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares }) + '\n');
131
92
  out.push('', ` Card: ${p.svg}`);
132
93
  if (args.includes('--png')) { const png = pngCard(p.svg); out.push(png ? ` Image to share: ${png}` : ' No image tool here (rsvg-convert or Chrome): open the card in a browser to save an image.'); }
133
94
  return out.join('\n');
package/status.mjs CHANGED
@@ -137,7 +137,7 @@ if (!isGit) {
137
137
  } else if (state === 'watching') {
138
138
  out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}. It verifies each commit/turn automatically.`);
139
139
  } else if (state === 'ready') {
140
- out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach --claude\``);
140
+ out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach\``);
141
141
  } else {
142
142
  out.push(` Measuring here · ${hasConfig ? 'a contract exists' : 'no test command detected yet'}. \`sawbuck init\` to set up the check, or \`sawbuck context\` for the history alone.`);
143
143
  }
package/dispatch.mjs DELETED
@@ -1,34 +0,0 @@
1
- #!/usr/bin/env node
2
- // dispatch.mjs, the dispatch: a compact proof-of-work receipt from the results.
3
- // A receipt, not an ad. Silent when nothing's been recorded to show (unless --force).
4
- // Zero-dep. Usage: node dispatch.mjs [--repo <path>] [--force] [--json]
5
-
6
- import { readFileSync } from 'node:fs';
7
- import { join } from 'node:path';
8
-
9
- const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
10
- const repo = flag('--repo', process.cwd());
11
- const force = process.argv.includes('--force');
12
-
13
- let rec = null;
14
- try { rec = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); } catch {}
15
-
16
- const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) + (rec.mandates || 0) : 0;
17
- if (!rec || (!total && !force)) process.exit(0); // nothing recorded → stay quiet
18
- if (process.argv.includes('--json')) { console.log(JSON.stringify(rec)); process.exit(0); }
19
-
20
- const n = (x, s) => `${x} ${s}${x === 1 ? '' : 's'}`;
21
- const L = rec.latest;
22
- // Attribute the latest catch to the agent that made it (the Claude hook passes 'claude').
23
- let head;
24
- if (L && L.kind === 'gamed') head = L.by ? `🐴 Sawbuck caught ${L.by} gaming a metric` : `🐴 Sawbuck caught a gamed change`;
25
- else if (L && L.kind === 'regression') head = L.by ? `🐴 Sawbuck caught a regression from ${L.by}` : `🐴 Sawbuck caught a regression`;
26
- else head = `🐴 Sawbuck`;
27
- let line = head;
28
- if (L) {
29
- const why = String(L.reason || L.kind).replace(/^a change here was gamed,\s*/i, '').replace(/^a change here\s+/i, '');
30
- line += `\n ${L.file}: ${why}`;
31
- }
32
- const tally = [rec.gamed ? n(rec.gamed, 'gamed change') : '', rec.regressions ? n(rec.regressions, 'regression') : ''].filter(Boolean).join(', ');
33
- if (tally) line += `\n (${tally} caught here so far)`;
34
- console.log(line);