sawbuck 0.1.41 → 0.1.43

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/README.md CHANGED
@@ -21,10 +21,10 @@ Or from the command line:
21
21
  # 1 · try it (no install, no commitment): verify your latest change right now
22
22
  npx sawbuck check
23
23
 
24
- # 2 · enforce it: attach once to this repo
24
+ # 2 · sign this repo: the written rule, your tests, protected paths, CI
25
25
  npm i -g sawbuck
26
- sawbuck attach # every commit (a git post-commit hook)
27
- sawbuck attach --claude # or protected paths every Claude Code turn, plus every commit (a project-scoped Stop hook)
26
+ sawbuck contract
27
+ sawbuck attach --claude # then watch every Claude Code turn
28
28
  ```
29
29
 
30
30
  **Zero-config:** it auto-detects your test command, so there is nothing to write to start.
@@ -60,19 +60,17 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
60
60
 
61
61
  | command | what |
62
62
  |---|---|
63
+ | `sawbuck contract` | one command to sign a repo: keeps your contract, adds tests and tripwires if there are none, protected paths, the supply-chain guards, and the architecture lock. `--dry-run` previews it. |
63
64
  | `sawbuck check [--base R --head R] [--repeat N] [--allow "reason"] [--json]` | verify your latest change (working tree if dirty, else the last commit, or a range); zero-config, no attach, no record. Every `promptwheel run` flag passes through: this is the engine with a plain-spoken verdict |
64
- | `sawbuck init` | write the contract (`promptwheel.config.json`), tuned by what your git history shows actually breaks |
65
65
  | `sawbuck attach [--claude \| --user]` | enforce it (git post-commit / project Claude hooks / user-level for every project) |
66
66
  | `sawbuck detach [--claude \| --user]` | remove it; no flag reverses whatever is attached here |
67
+ | `/sawbuck-reins:rules` | proposes the rules you keep enforcing, from your own messages, each with quotes; you pick what to keep. Tested on one person so far |
68
+ | `/sawbuck-reins:shape` | what you push on, what the agent did, and the gap between them, as a card |
67
69
  | `sawbuck status` · `sawbuck log` | the inventory (caught over time + debt now + armed state), in one view; and the recent per-check feed |
68
70
  | `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
69
71
  | `sawbuck context` | the ranked relevant history for the current change |
70
- | `sawbuck frame` | the contract's hard rules (mandates, inherited ones included) as one compact block; silent without a contract |
71
- | `sawbuck turn` | frame + context in one block: what `attach --claude` runs on every prompt and again right after a compaction |
72
- | `sawbuck recall` | related notes for your current change from extra memory providers you compose in (bring your own) |
73
72
  | `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
74
- | `sawbuck contract` | one command to sign a repo: keeps your contract, adds tests and tripwires if there are none, protected paths, the supply-chain guards, and the architecture lock. `--dry-run` previews it. |
75
- | `sawbuck where` · `sawbuck statusline` | list every repo it is attached to; a one-line status-line segment (🐴 watching / N caught / steer %) |
73
+ | `sawbuck statusline` | a one-line status-line segment (🐴 watching / N caught / steer %) |
76
74
 
77
75
  ## Give your agent the contract
78
76
 
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ // Your standing rules, one per line, kept in ~/.sawbuck/rules.md: the rules you picked from /sawbuck-reins:rules. Signing
6
+ // carries them into each repo, so they follow you. They are your words, and they land in a file you review before committing.
7
+ export function standingRules(home = homedir()) {
8
+ try {
9
+ return readFileSync(join(home, '.sawbuck', 'rules.md'), 'utf8').split('\n').map((l) => l.replace(/^[-*\s]+/, '').trim()).filter((l) => l && !l.startsWith('#') && l.length <= 200).slice(0, 30);
10
+ } catch { return []; }
11
+ }
12
+
13
+ // Write (or refresh) the agent-orienting contract block so a coding agent (or a loop not wired to the
14
+ // `attach --claude` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
15
+ // CLAUDE.md / .cursorrules when present. Idempotent + safe: only the sentinel-delimited block is
16
+ // touched. `--no-agent-block` skips it.
17
+ export function writeAgentBlock(repo) {
18
+ const START = '<!-- sawbuck:contract -->', END = '<!-- /sawbuck:contract -->';
19
+ const block = `${START}\n## Sawbuck contract\n\nThis repo is under a Sawbuck contract: a change is done only when the win survives verification from the source alone. Before finishing, run \`npx -y sawbuck check\`. If it reads \`gamed\`, the green came from editing a test, config, or grader, not the source, so undo that and earn it in the source. Run \`npx -y sawbuck context\` after your first edit to see what broke in these files before (reverts, weakened tests, fragile spots). Never skip, weaken, or delete a test to go green.\n${END}\n`;
20
+ const mine = standingRules();
21
+ const body = mine.length ? block.replace(END, `\n### Standing rules\n\n${mine.map((r) => `- ${r}`).join('\n')}\n${END}`) : block;
22
+ const targets = ['AGENTS.md', ...['CLAUDE.md', '.cursorrules'].filter((f) => existsSync(join(repo, f)))];
23
+ const done = [];
24
+ for (const f of targets) {
25
+ const p = join(repo, f);
26
+ let text = existsSync(p) ? readFileSync(p, 'utf8') : '';
27
+ const s = text.indexOf(START);
28
+ if (s > -1) { const e = text.indexOf(END, s); text = (e > -1 ? text.slice(0, s) + body + text.slice(e + END.length).replace(/^\n+/, '\n') : text.slice(0, s) + body); }
29
+ else { text = (text.trim() ? text.replace(/\n*$/, '\n\n') : '') + body; }
30
+ try { writeFileSync(p, text); done.push(f); } catch { /* best-effort */ }
31
+ }
32
+ return done;
33
+ }
package/contract-cmd.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // lock. Zero-LLM. Re-running changes nothing. Protected product code (auth, billing) is suggested, never added:
5
5
  // freezing those is a person's decision.
6
6
  import { ensureExcluded } from './exclude.mjs';
7
+ import { writeAgentBlock, standingRules } from './agent-block.mjs';
7
8
  import { execFileSync } from 'node:child_process';
8
9
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, mkdirSync } from 'node:fs';
9
10
  import { join, dirname, resolve } from 'node:path';
@@ -63,7 +64,7 @@ try {
63
64
  writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
64
65
  const pack = join(repo, '.promptwheel', 'packs', 'supply-chain');
65
66
  mkdirSync(pack, { recursive: true });
66
- cpSync(join(scratch, '.promptwheel', 'packs', 'supply-chain'), pack, { recursive: true });
67
+ cpSync(join(scratch, '.promptwheel', 'packs', 'supply-chain'), pack, { recursive: true, filter: (f) => !/(README\.md|pack\.json)$/.test(f) });
67
68
  // the gate measures in clean worktrees, so the scanners must reach git. A repo attached earlier, or one that
68
69
  // ignores .promptwheel/ for the outcome record, would hide them and every guard would read unmeasurable.
69
70
  for (const f of [join(repo, '.gitignore'), resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8' }).trim())]) {
@@ -73,6 +74,8 @@ try {
73
74
  if (next !== text) writeFileSync(f, next);
74
75
  }
75
76
  ensureExcluded(repo);
77
+ // the written rule is what changes the agent (the pressure bench: 11 of 20 to 0 of 20), so every signing writes it
78
+ writeAgentBlock(repo);
76
79
  // CI goes in here, before the rules take hold: afterwards the contract itself would refuse an agent the file.
77
80
  // Only for a GitHub repo with a real test command: a placeholder would fail every pull request.
78
81
  const wf = join(repo, '.github', 'workflows', 'sawbuck.yml');
@@ -83,7 +86,7 @@ try {
83
86
  const locked = existsSync(join(repo, 'promptwheel.lock.json'));
84
87
  if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
85
88
  const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
86
- process.stdout.write(added || !locked ? `sawbuck: signed. ${cfg.metrics.length} guards, ${cfg.mandates.length} protected-path rules, and the shape of this repo in promptwheel.lock.json.\n${strict ? '' : ' New dependencies and structure changes show as notes for now; protected paths and install scripts block. --strict blocks them all.\n'}${ci ? ' Added .github/workflows/sawbuck.yml, so pull requests are checked in CI. Commit it with the contract.\n' : ''}` : 'sawbuck: already signed, nothing changed.\n');
89
+ process.stdout.write(added || !locked ? `sawbuck: signed. ${cfg.metrics.length} guards, ${cfg.mandates.length} protected-path rules, and the shape of this repo in promptwheel.lock.json.\n${strict ? '' : ' New dependencies and structure changes show as notes for now; protected paths and install scripts block. --strict blocks them all.\n'}${standingRules().length ? ` Added your ${standingRules().length} standing rules from ~/.sawbuck/rules.md to AGENTS.md.\n` : ''}${ci ? ' Added .github/workflows/sawbuck.yml, so pull requests are checked in CI. Commit it with the contract.\n' : ''}` : 'sawbuck: already signed, nothing changed.\n');
87
90
  }
88
91
  } finally { rmSync(scratch, { recursive: true, force: true }); }
89
92
 
@@ -92,5 +95,5 @@ let files = [];
92
95
  try { files = execFileSync('git', ['-C', repo, 'ls-files'], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }).split('\n'); } catch { /* not a repo: attach says so */ }
93
96
  const seen = new Set();
94
97
  for (const f of files) { const m = f.match(/^(.*?(?:^|\/)(?:auth|authentication|billing|payments?|migrations))\//); if (m && !/(^|\/)(node_modules|test|tests|docs?)\//.test(m[1])) seen.add(m[1]); }
95
- if (seen.size) process.stdout.write(`\n To protect product code too, add these to "mandates" in promptwheel.config.json:\n${[...seen].slice(0, 5).map((d) => ` { "name": "${d.replace(/\W+/g, '-')}", "paths": ["${d}/**"], "reason": "changed by a person" }`).join('\n')}\n`);
96
- if (!dry) process.stdout.write('\n Commit promptwheel.config.json, promptwheel.lock.json and .promptwheel/packs/. Then /sawbuck-reins:watch or sawbuck attach --claude arms it.\n');
98
+ if (seen.size) process.stdout.write(`\n To protect product code too, add these protected paths under "mandates" in promptwheel.config.json:\n${[...seen].slice(0, 5).map((d) => ` { "name": "${d.replace(/\W+/g, '-')}", "paths": ["${d}/**"], "reason": "changed by a person" }`).join('\n')}\n`);
99
+ if (!dry) process.stdout.write('\n Commit promptwheel.config.json, promptwheel.lock.json, AGENTS.md and .promptwheel/packs/. Then sawbuck attach --claude arms it.\n');
package/dispatch.mjs CHANGED
@@ -13,7 +13,7 @@ const force = process.argv.includes('--force');
13
13
  let rec = null;
14
14
  try { rec = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); } catch {}
15
15
 
16
- const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
16
+ const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) + (rec.mandates || 0) : 0;
17
17
  if (!rec || (!total && !force)) process.exit(0); // nothing recorded → stay quiet
18
18
  if (process.argv.includes('--json')) { console.log(JSON.stringify(rec)); process.exit(0); }
19
19
 
package/exclude.mjs CHANGED
@@ -5,6 +5,8 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
5
  import { resolve, dirname } from 'node:path';
6
6
  const sh = (r, ...a) => { try { return execFileSync('git', ['-C', r, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
7
7
 
8
+ export const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
9
+
8
10
  export function ensureExcluded(r) {
9
11
  try {
10
12
  // the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
@@ -13,7 +15,7 @@ export function ensureExcluded(r) {
13
15
  const f = resolve(r, rel);
14
16
  // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
15
17
  // the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
16
- const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/*\n!.promptwheel/packs/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n.claude/settings.local.json\n# <<< sawbuck <<<\n';
18
+ const WANT = `# >>> sawbuck >>>\n${[...RECORD_DIRS, '.claude/settings.local.json'].join('\n')}\n# <<< sawbuck <<<\n`;
17
19
  const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
18
20
  const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
19
21
  const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
package/exposure.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // What was not said. The shape records what you pushed on; this records what the agent did, from your transcripts, so a
2
+ // spoke can be read against its opportunity: an agent that often changes code and runs nothing, in history where you
3
+ // rarely ask for proof, is a blind spot. Plain counting of tool calls. No model, nothing leaves this machine.
4
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { readHistory, typedText } from './history.mjs';
8
+
9
+ const VERIFY = /\b(test|tests|pytest|vitest|jest|mocha|ava|playwright|cargo (test|check)|go (test|vet)|tsc|eslint|lint|typecheck|npm run (build|check)|curl|sawbuck check|promptwheel run|make (test|check))\b/i;
10
+ const RISKY_PATH = /(^|\/)(\.env[^/]*|\.npmrc|\.yarnrc(\.yml)?|\.github\/workflows\/[^/]+|Dockerfile[^/]*|docker-compose[^/]*|CODEOWNERS|\.claude\/settings(\.local)?\.json|[^/]*(secret|credential)[^/]*)$/i;
11
+ const RISKY_CMD = /(git push[^|;&]*(--force|-f\b)|git reset --hard|curl[^|]*\|\s*(sh|bash)|sudo |drop table|chmod 777)/i;
12
+ // deleting a temp or build folder is housekeeping, not risk: on real history two thirds of rm -rf were that
13
+ const RM = /rm -rf\s+(\S+)/i, RM_SAFE = /tmp|scratch|\$|dist|build|node_modules|cache|\.next|coverage/i;
14
+ const SHIP = /\b(git commit|git push|npm publish|gh pr (create|merge)|gh release|vercel|deploy)\b/i;
15
+ const UI = /\.(css|scss|sass|less|tsx|jsx|vue|svelte|astro|html|svg)$/i;
16
+ const COPY = /\.(md|mdx|txt|rst)$/i;
17
+ const CODE = /\.(m?[jt]sx?|py|go|rs|rb|java|kt|cs|php|c|cc|cpp|h|swift|sh)$/i;
18
+ const BIG = 200;
19
+ const SCRATCH = /(^|\/)(tmp|scratchpad|scratch)\/|^\/tmp\/|\/\.claude\//; // throwaway work does not need a test run
20
+ const DAYS = 90; // what to work on now, not what happened last year
21
+
22
+ // Rules you have already written, by spoke. A plain keyword match on your own files, so it is a hint: it finds that a
23
+ // rule on the subject exists, not that it says the right thing.
24
+ const WRITTEN = {
25
+ proof: /\b(verify|verified|run the tests?|tests? pass|prove|proof|before claiming|before trusting)\b/i,
26
+ safety: /\b(secrets?|credentials?|force.?push|destructive|protected paths?|never delete|before deleting)\b/i,
27
+ simplicity: /\b(simple|simplify|bloat|smallest|minimal|keep it short|overengineer\w*)\b/i,
28
+ process: /\b(commit|push|publish|release|deploy)\b/i,
29
+ voice: /\b(em dash\w*|plain (words|language|voice)|tone|wording|ai tells?|voice)\b/i,
30
+ visual: /\b(design|layout|css|ui|colou?rs?|spacing)\b/i,
31
+ naming: /\b(naming|names?|rename)\b/i,
32
+ autonomy: /\b(do not ask|don't ask|ask first|ask before|proceed without|keep going)\b/i,
33
+ };
34
+ export function writtenRules({ home = homedir(), repo = process.cwd() } = {}) {
35
+ const files = [join(home, '.claude', 'CLAUDE.md'), join(home, '.sawbuck', 'rules.md'), join(repo, 'CLAUDE.md'), join(repo, 'AGENTS.md')];
36
+ const root = join(home, '.claude', 'projects');
37
+ try { for (const d of readdirSync(root)) { const m = join(root, d, 'memory'); if (existsSync(m)) for (const f of readdirSync(m)) if (/^feedback.*\.md$/.test(f)) files.push(join(m, f)); } } catch {}
38
+ let text = '';
39
+ for (const f of files) { try { text += '\n' + readFileSync(f, 'utf8'); } catch {} }
40
+ return Object.fromEntries(Object.entries(WRITTEN).map(([s, re]) => [s, re.test(text)]));
41
+ }
42
+
43
+ function lines(s) { return s ? String(s).split('\n').length : 0; }
44
+
45
+ export function exposure({ root = join(homedir(), '.claude', 'projects'), days = DAYS } = {}) {
46
+ const since = days ? Date.now() - days * 864e5 : 0;
47
+ const WINDOW = 3; // a check may come a turn or two later: count code as unchecked only if none ran in the next 3 turns
48
+ const c = { turns: 0, working: 0, code: 0, unverified: 0, risky: 0, big: 0, shipped: 0, ui: 0, copy: 0, renames: 0, asked: 0 };
49
+ let turn = null, T = [];
50
+ const close = () => {
51
+ if (!turn) return;
52
+ c.turns++; T.push(turn);
53
+ if (turn.tools) {
54
+ c.working++;
55
+ if (turn.code) c.code++;
56
+ if (turn.risky) c.risky++; if (turn.added >= BIG) c.big++; if (turn.ship) c.shipped++; if (turn.ui) c.ui++; if (turn.copy) c.copy++; if (turn.rename) c.renames++;
57
+ if (/\?\s*$/.test(turn.last)) c.asked++;
58
+ }
59
+ turn = null;
60
+ };
61
+ const endSession = () => {
62
+ close();
63
+ T.forEach((t, i) => { if (t.code && !T.slice(i, i + 1 + WINDOW).some((x) => x.verified)) c.unverified++; });
64
+ T = [];
65
+ };
66
+ const onTool = (b) => {
67
+ turn.tools++;
68
+ const i = b.input || {};
69
+ if (b.name === 'Bash') {
70
+ const cmd = String(i.command || '');
71
+ if (VERIFY.test(cmd)) turn.verified = true;
72
+ const rm = cmd.match(RM);
73
+ if (RISKY_CMD.test(cmd) || (rm && !RM_SAFE.test(rm[1]))) turn.risky = true;
74
+ if (SHIP.test(cmd)) turn.ship = true;
75
+ if (/\bgit mv\b/.test(cmd)) turn.rename = true;
76
+ } else if (b.name === 'Edit' || b.name === 'Write' || b.name === 'MultiEdit') {
77
+ const p = String(i.file_path || '');
78
+ if (CODE.test(p) && !SCRATCH.test(p)) turn.code++;
79
+ if (UI.test(p)) turn.ui = true;
80
+ if (COPY.test(p)) turn.copy = true;
81
+ if (RISKY_PATH.test(p)) turn.risky = true;
82
+ if (i.replace_all) turn.rename = true;
83
+ turn.added += Math.max(0, lines(i.content || i.new_string) - lines(i.old_string));
84
+ }
85
+ };
86
+ readHistory(root, {
87
+ onSession: endSession,
88
+ onEvent: (e) => {
89
+ if (since && e.timestamp && Date.parse(e.timestamp) < since) return;
90
+ if (e.type === 'user') {
91
+ if (typedText(e.message) === null) return;
92
+ close();
93
+ turn = { tools: 0, code: 0, verified: false, risky: false, added: 0, ship: false, ui: false, copy: false, rename: false, last: '' };
94
+ } else if (turn && Array.isArray(e.message && e.message.content)) {
95
+ for (const b of e.message.content) {
96
+ if (b.type === 'text') turn.last = b.text || turn.last;
97
+ if (b.type === 'tool_use') onTool(b);
98
+ }
99
+ }
100
+ },
101
+ });
102
+ return c;
103
+ }
104
+ if (import.meta.url === `file://${process.argv[1]}`) console.log(JSON.stringify(exposure()));
package/history.mjs ADDED
@@ -0,0 +1,32 @@
1
+ // One reader for Claude Code history on this machine, so every feature counts the same way: side threads and meta lines
2
+ // are skipped, and a line copied by a resumed session counts once.
3
+ import { readdirSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export function typedText(message) {
7
+ const c = message && message.content;
8
+ if (typeof c === 'string') return c;
9
+ if (Array.isArray(c) && c.length && c.every((b) => b.type === 'text')) return c.map((b) => b.text).join(' ');
10
+ return null;
11
+ }
12
+
13
+ // calls onSession() before each transcript and onEvent(e) for each line, and returns nothing
14
+ export function readHistory(root, { onSession = () => {}, onEvent }) {
15
+ const seen = new Set();
16
+ let dirs = []; try { dirs = readdirSync(root); } catch { return; }
17
+ for (const d of dirs) {
18
+ let files = []; try { files = readdirSync(join(root, d)).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
19
+ for (const f of files) {
20
+ let text = ''; try { text = readFileSync(join(root, d, f), 'utf8'); } catch { continue; }
21
+ onSession();
22
+ for (const line of text.split('\n')) {
23
+ if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue;
24
+ let e; try { e = JSON.parse(line); } catch { continue; }
25
+ if (e.isSidechain || e.isMeta) continue;
26
+ if (e.uuid) { if (seen.has(e.uuid)) continue; seen.add(e.uuid); }
27
+ onEvent(e);
28
+ }
29
+ }
30
+ }
31
+ onSession();
32
+ }
package/init.mjs CHANGED
@@ -15,6 +15,7 @@
15
15
  // --force overwrite an existing promptwheel.config.json
16
16
  // --preset <name> pass through to `promptwheel init`
17
17
 
18
+ import { writeAgentBlock } from './agent-block.mjs';
18
19
  import { execFileSync } from 'node:child_process';
19
20
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
20
21
  import { join } from 'node:path';
@@ -104,29 +105,9 @@ function describeMetric(m) {
104
105
 
105
106
  function writeCfg() { writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n'); }
106
107
 
107
- // Write (or refresh) the agent-orienting contract block so a coding agent (or a loop not wired to the
108
- // `attach --claude` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
109
- // CLAUDE.md / .cursorrules when present. Idempotent + safe: only the sentinel-delimited block is
110
- // touched. `--no-agent-block` skips it.
111
- function writeAgentBlock() {
112
- const START = '<!-- sawbuck:contract -->', END = '<!-- /sawbuck:contract -->';
113
- const block = `${START}\n## Sawbuck contract\n\nThis repo is under a Sawbuck contract: a change is done only when the win survives verification from the source alone. Before finishing, run \`sawbuck check\`. If it reads \`gamed\`, the green came from editing a test, config, or grader, not the source, so undo that and earn it in the source. Run \`sawbuck context\` first to see what broke in these files before (reverts, weakened tests, fragile spots). Never skip, weaken, or delete a test to go green.\n${END}\n`;
114
- const targets = ['AGENTS.md', ...['CLAUDE.md', '.cursorrules'].filter((f) => existsSync(join(repo, f)))];
115
- const done = [];
116
- for (const f of targets) {
117
- const p = join(repo, f);
118
- let text = existsSync(p) ? readFileSync(p, 'utf8') : '';
119
- const s = text.indexOf(START);
120
- if (s > -1) { const e = text.indexOf(END, s); text = (e > -1 ? text.slice(0, s) + block + text.slice(e + END.length).replace(/^\n+/, '\n') : text.slice(0, s) + block); }
121
- else { text = (text.trim() ? text.replace(/\n*$/, '\n\n') : '') + block; }
122
- try { writeFileSync(p, text); done.push(f); } catch { /* best-effort */ }
123
- }
124
- return done;
125
- }
126
-
127
108
  function report() {
128
109
  out('sawbuck: wrote promptwheel.config.json');
129
- if (!noBlock) { const w = writeAgentBlock(); if (w.length) out(`sawbuck: wrote the agent guide (the contract rules) to ${w.join(', ')}`); }
110
+ if (!noBlock) { const w = writeAgentBlock(repo); if (w.length) out(`sawbuck: wrote the agent guide (the contract rules) to ${w.join(', ')}`); }
130
111
  out("\n the contract (what a win must satisfy):");
131
112
  for (const m of cfg.metrics) out(' - ' + describeMetric(m));
132
113
  if (evidence) { out('\n investigated this repo:'); for (const l of evidenceLines()) out(l); }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.41",
4
- "description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves each win from the agent's source edits alone, so a test that went green by editing the test, grader, or config is sent back. For Claude Code, with Codex support in beta.",
3
+ "version": "0.1.43",
4
+ "description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves each win from the agent's source edits alone, so a test that went green by editing the test, grader, or config is sent back. For Claude Code, with Codex untested.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "engines": {
@@ -34,12 +34,17 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@promptwheel/logbook": "0.9.10",
37
- "promptwheel": "0.7.15"
37
+ "promptwheel": "0.7.16"
38
38
  },
39
39
  "files": [
40
40
  "sawbuck.mjs",
41
41
  "rewrites.mjs",
42
42
  "steers.mjs",
43
+ "rules.mjs",
44
+ "history.mjs",
45
+ "shape.mjs",
46
+ "exposure.mjs",
47
+ "agent-block.mjs",
43
48
  "exclude.mjs",
44
49
  "contract-cmd.mjs",
45
50
  "bins.mjs",
package/reflect.mjs CHANGED
@@ -12,7 +12,7 @@
12
12
  // node reflect.mjs [--repo <path>] [--base <ref>] [--head <ref>]
13
13
  // defaults: repo=cwd, base=HEAD~1, head=HEAD (evaluates the latest commit)
14
14
  //
15
- // Engines resolve to the monorepo siblings by default; PW_BIN / LB_BIN override.
15
+ // PW_BIN / LB_BIN override the engines.
16
16
 
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
package/rules.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // Find the rules you keep enforcing and never wrote down. `prepare` gathers what you typed to Claude Code, on this
2
+ // machine, with copies and likely secrets removed. The agent proposes rules from it, each with quotes. `check` proves
3
+ // every quote is really yours: a rule with fewer than two real quotes is dropped. Nothing is added to any file here.
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { readHistory, typedText } from './history.mjs';
8
+
9
+ const MAX_LINES = 3000;
10
+ const SECRET = /(sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9]{20,}|github_pat_\w{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[\w-]{10,}|eyJ[\w-]{20,}\.[\w-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY|\b[a-f0-9]{40,}\b|(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)/i;
11
+ const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
12
+ const paths = (home) => ({ dir: join(home, '.sawbuck'), input: join(home, '.sawbuck', 'rule-input.txt') });
13
+
14
+ export function prepareRules({ home = homedir() } = {}) {
15
+ const found = [];
16
+ const seen = new Set();
17
+ let secrets = 0;
18
+ readHistory(join(home, '.claude', 'projects'), {
19
+ onEvent: (e) => {
20
+ if (e.type !== 'user') return;
21
+ const t = (typedText(e.message) || '').replace(/\s+/g, ' ').trim();
22
+ if (t.length < 8 || t.length > 400 || /^[<[]/.test(t) || t.startsWith('Caveat:') || t.startsWith('This session is being continued')) return;
23
+ const k = norm(t).slice(0, 120);
24
+ if (seen.has(k)) return;
25
+ seen.add(k);
26
+ if (SECRET.test(t)) { secrets++; return; }
27
+ found.push({ t, at: e.timestamp || '' });
28
+ },
29
+ });
30
+ found.sort((a, b) => String(a.at).localeCompare(String(b.at)));
31
+ const lines = found.slice(-MAX_LINES).map((x) => x.t);
32
+ const { dir, input } = paths(home);
33
+ mkdirSync(dir, { recursive: true });
34
+ writeFileSync(input, lines.join('\n') + '\n', { mode: 0o600 });
35
+ return { input, lines: lines.length, secrets };
36
+ }
37
+
38
+ export function checkRules(file, { home = homedir() } = {}) {
39
+ const lines = readFileSync(paths(home).input, 'utf8').split('\n').map(norm);
40
+ const raw = readFileSync(file, 'utf8');
41
+ const m = raw.match(/\[[\s\S]*\]/);
42
+ const list = m ? JSON.parse(m[0]) : [];
43
+ const kept = [], dropped = [];
44
+ for (const r of list) {
45
+ const quotes = (r.quotes || []).filter((q) => { const n = norm(String(q)); return n.length >= 8 && lines.some((l) => l.includes(n)); });
46
+ (quotes.length >= 2 ? kept : dropped).push({ rule: String(r.rule), quotes });
47
+ }
48
+ return { kept, dropped };
49
+ }
50
+
51
+ export function rulesCli(args, { home = homedir() } = {}) {
52
+ if (args.includes('--prepare')) {
53
+ const r = prepareRules({ home });
54
+ return `🐴 ${r.lines} of your messages are ready in ${r.input}, copies removed${r.secrets ? `, ${r.secrets} that looked like secrets left out` : ''}. Stays on this machine until an agent reads it.`;
55
+ }
56
+ const i = args.indexOf('--check');
57
+ if (i > -1) {
58
+ const file = args[i + 1];
59
+ if (!file || !existsSync(file) || !statSync(file).isFile()) return '🐴 sawbuck: --check needs the candidates file.';
60
+ if (!existsSync(paths(home).input)) return '🐴 sawbuck: run sawbuck rules --prepare first.';
61
+ const { kept, dropped } = checkRules(file, { home });
62
+ const out = [`🐴 ${kept.length} rules have at least 2 quotes that are really yours${dropped.length ? `, ${dropped.length} dropped` : ''}.`];
63
+ kept.forEach((r, n) => { out.push('', `${n + 1}. ${r.rule}`); for (const q of r.quotes.slice(0, 3)) out.push(` "${q.slice(0, 110)}"`); });
64
+ return out.join('\n');
65
+ }
66
+ return 'sawbuck rules --prepare | --check <candidates.json>';
67
+ }
package/sawbuck-hook.mjs CHANGED
@@ -32,8 +32,7 @@ const node = (f, a) => { try { return execFileSync(process.execPath, [f, ...a],
32
32
 
33
33
  let cfg = {};
34
34
  try { cfg = JSON.parse(readFileSync(join(repo, 'sawbuck.config.json'), 'utf8')); } catch {}
35
- const everyTurns = cfg.dispatch?.everyTurns ?? 5;
36
- const onlyOnCatch = cfg.dispatch?.onlyOnCatch ?? false;
35
+ const EVERY_TURNS = 5;
37
36
 
38
37
  const stDir = join(repo, '.sawbuck');
39
38
  const stPath = join(stDir, 'state.json');
@@ -71,7 +70,7 @@ st.lastSha = head || st.lastSha;
71
70
  st.turns += 1;
72
71
 
73
72
  // 2) the record of what the check has charted (read above, after any re-run)
74
- const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
73
+ const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) + (rec.mandates || 0) : 0;
75
74
  const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
76
75
 
77
76
  // 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
@@ -123,7 +122,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
123
122
  const before = (st.lastViolation && st.lastViolation.before) || {};
124
123
  const restore = Object.keys(before).length ? ' To put back what you overwrote: ' + Object.entries(before).map(([f, h]) => `restore ${f} to the content it had when the prompt began: git cat-file -p ${h} > ${f}`).join('; ') + '.' : '';
125
124
  const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
126
- const accept = committedHit ? ' To accept this change on purpose, change the mandate in the contract or revert the path.' : '';
125
+ const accept = committedHit ? ' To accept this change on purpose, change the rule in the contract or revert the path.' : '';
127
126
  const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach --claude again, which upgrades the hooks.)';
128
127
  // one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
129
128
  // a block must not replace a gamed verdict that is still standing.
@@ -138,7 +137,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
138
137
  if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
139
138
  st.mandateBlocks = (st.mandateBlocks || 0) + 1;
140
139
  saveState();
141
- process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a mandate in the contract was violated, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${restore}${accept}${upgrade}` }));
140
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a protected path in the contract was touched, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${restore}${accept}${upgrade}` }));
142
141
  process.exit(0);
143
142
  }
144
143
  saveState();
@@ -191,8 +190,8 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE && st.turnBaseline && st.turnBaselin
191
190
 
192
191
  // 4) otherwise, surface a catch on a fresh catch or on cadence (a flag; never blocks)
193
192
  const freshCatch = total > (st.shownTotal || 0);
194
- const cadenceHit = everyTurns > 0 && st.turns % everyTurns === 0;
195
- if (freshCatch || (cadenceHit && !onlyOnCatch)) {
193
+ const cadenceHit = st.turns % EVERY_TURNS === 0;
194
+ if (freshCatch || cadenceHit) {
196
195
  const out = node(join(HERE, 'dispatch.mjs'), ['--repo', repo]);
197
196
  if (out.trim()) process.stdout.write(out.endsWith('\n') ? out : out + '\n');
198
197
  st.shownTotal = total;
package/sawbuck.mjs CHANGED
@@ -15,7 +15,9 @@
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { erasedBlock } from './rewrites.mjs';
17
17
  import { steersReport } from './steers.mjs';
18
- import { ensureExcluded } from './exclude.mjs';
18
+ import { rulesCli } from './rules.mjs';
19
+ import { shapeCli } from './shape.mjs';
20
+ import { ensureExcluded, RECORD_DIRS } from './exclude.mjs';
19
21
  import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
20
22
  import { basename, join, dirname, resolve } from 'node:path';
21
23
  import { fileURLToPath } from 'node:url';
@@ -46,7 +48,6 @@ const BLOCK_RE = new RegExp(`\\n?${S_START}[\\s\\S]*?${S_END}\\n?`, 'g');
46
48
  // plus the logbook's root working-set (ledger cache, digests, annotation planes). It is
47
49
  // memory, not the user's source, a user who wants LOGBOOK.md committed still can explicitly
48
50
  // (exclude only affects UNtracked files).
49
- const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
50
51
 
51
52
  const argv = process.argv.slice(2);
52
53
  const cmd = argv[0];
@@ -69,7 +70,7 @@ Usage:
69
70
  fastest way to see it work. Every promptwheel run flag passes through.
70
71
  Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
71
72
  sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
72
- Write the contract (promptwheel.config.json), tuned and explained by what your
73
+ Customize the contract (promptwheel.config.json), with notes from what your
73
74
  git history shows actually breaks, plus a short contract guide in AGENTS.md
74
75
  (--no-agent-block to skip). Wizard in a terminal, non-interactive in a pipe/CI (or --yes).
75
76
  sawbuck attach [repo] Enforce the contract on every commit (a git post-commit hook)
@@ -86,7 +87,7 @@ Usage:
86
87
  The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
87
88
  shows only what a branch adds; --fail-on exits nonzero (a CI gate)
88
89
  sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
89
- sawbuck frame [repo] The contract's hard rules (mandates, inherited ones included) as one compact block;
90
+ sawbuck frame [repo] The contract's protected paths, inherited ones included, as one compact block;
90
91
  silent without a contract (--speak explains)
91
92
  sawbuck turn [repo] frame + context in one block: what attach --claude runs on every prompt
92
93
 
@@ -94,6 +95,10 @@ Usage:
94
95
  sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
95
96
  sawbuck where List every repo Sawbuck is attached to
96
97
  sawbuck contract Sign this repo: tests, tripwires, protected paths, and the architecture lock (--dry-run to preview)
98
+ sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
99
+ sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
100
+ 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
97
102
  sawbuck steers What you keep correcting, from the plugin's scratchpad on this machine (--json)
98
103
  sawbuck statusline One-line segment for your status line (🐴 watching | N caught | steer %)
99
104
 
@@ -182,7 +187,7 @@ switch (cmd) {
182
187
  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); }
183
188
  if (json) { out(JSON.stringify(rep)); process.exit(code); }
184
189
  const m = (rep.metrics || [])[0];
185
- for (const md of rep.mandates || []) out(`🐴 ⛔ mandate "${md.name}" violated${md.reason ? ': ' + md.reason : ''} (touched: ${(md.files || []).join(', ')})${md.removed ? ' [deleted in this same change; a mandate stays in force for the change that removes it]' : ''}`);
190
+ 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]' : ''}`);
186
191
  const win = (rep.metrics || []).find((x) => x.status === 'improved' && !x.tripwire);
187
192
  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
193
  else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
@@ -232,7 +237,7 @@ switch (cmd) {
232
237
  // cannot forget them. Silent without a contract; --speak explains instead.
233
238
  const o = frameBlock(repoRoot());
234
239
  if (o) out(o);
235
- else if (flags.has('--speak')) out('🐴 sawbuck frame: no mandates in promptwheel.config.json yet. Add one (a protected path) and this re-asserts it every turn and after every compaction, so a deep context cannot forget it.');
240
+ else if (flags.has('--speak')) out('🐴 sawbuck frame: no protected paths in promptwheel.config.json yet. Add one (a protected path) and this re-asserts it every turn and after every compaction, so a deep context cannot forget it.');
236
241
  break;
237
242
  }
238
243
  case 'turn': {
@@ -355,6 +360,8 @@ switch (cmd) {
355
360
  case 'trace': { traceRepo(repoRoot()); break; }
356
361
  case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
357
362
  case 'where': { whereAttached(); break; }
363
+ case 'rules': { out(rulesCli(rest)); break; }
364
+ case 'shape': { out(shapeCli(rest)); break; }
358
365
  case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
359
366
  case 'statusline': {
360
367
  // compact status-line segment for the cwd repo. Never throws; fast.
@@ -552,8 +559,14 @@ function wireClaude(repo, opts) {
552
559
  const fileExisted = existsSync(f);
553
560
  let cfg = {};
554
561
  if (fileExisted) { try { cfg = JSON.parse(readFileSync(f, 'utf8')); } catch { die(`${f} exists but is not valid JSON; I will not overwrite it. Add the hooks by hand.`); } }
555
- const ctx = user ? `${nodeCmd()} ${JSON.stringify(SELF)} turn --hook` : `${nodeCmd()} ${JSON.stringify(SELF)} turn --hook ${JSON.stringify(repo)}`;
556
- const chk = user ? `${nodeCmd()} ${JSON.stringify(SELF)} __hook --by claude --stop` : `${nodeCmd()} ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
562
+ // run through npx, the installed path lives in a cache that can be cleared, and a missing hook fails quietly and
563
+ // leaves the wall off: fall back to fetching the same version
564
+ const run = (args) => {
565
+ const direct = `${nodeCmd()} ${JSON.stringify(SELF)} ${args}`;
566
+ return process.platform !== 'win32' && SELF.includes('/_npx/') ? `if [ -f ${JSON.stringify(SELF)} ]; then ${direct}; else npx -y sawbuck@${version} ${args}; fi` : direct;
567
+ };
568
+ const ctx = run(user ? 'turn --hook' : `turn --hook ${JSON.stringify(repo)}`);
569
+ const chk = run(user ? '__hook --by claude --stop' : `__hook --repo ${JSON.stringify(repo)} --by claude --stop`);
557
570
  // attached already? "already attached" only when every hook of THIS version is present; an older
558
571
  // attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
559
572
  let upgrading = false;
@@ -607,6 +620,7 @@ function wireClaude(repo, opts) {
607
620
  regAdd(repo, mode);
608
621
  out(upgrading ? `sawbuck: updated the ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f} to this version`
609
622
  : `sawbuck: wired ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f}`);
623
+ 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.');
610
624
  if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
611
625
  else if (!shared) out(' (settings.local.json is gitignored; use --shared to commit the hooks for your team)');
612
626
  }
@@ -680,6 +694,8 @@ function purgeRecord(repo) {
680
694
  let n = 0;
681
695
  for (const d of RECORD_DIRS) {
682
696
  const p = join(repo, d);
697
+ // a committed LOGBOOK.md or JOURNEY.md is the person's file, not our record
698
+ try { if (execFileSync('git', ['-C', repo, 'ls-files', '--', d], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()) continue; } catch {}
683
699
  try { if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); n++; out(`sawbuck: purged ${p}`); } } catch {}
684
700
  }
685
701
  const outcomes = join(repo, '.promptwheel', 'outcomes.jsonl'); // the rest of .promptwheel/ is the signed contract: yours to keep or delete
@@ -721,7 +737,7 @@ function doctorAll() {
721
737
 
722
738
  // --- registry: know and remove every attachment -----------------------------
723
739
  function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
724
- function regRead() { try { return JSON.parse(readFileSync(regPath(), 'utf8')); } catch { return { repos: [] }; } }
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
725
741
  function regWrite(r) {
726
742
  const f = regPath();
727
743
  if (!r.repos.length) { delFile(f); rmIfEmptyDir(dirname(f)); return; }
package/shape.mjs ADDED
@@ -0,0 +1,110 @@
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.
4
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { prepareRules } from './rules.mjs';
8
+ import { exposure, writtenRules } from './exposure.mjs';
9
+
10
+ export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['direction', 'scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
11
+ const SPOKES = Object.values(GROUPS).flat();
12
+ const MIN_SPOKES = 200; // below this, spokes are noise: show the 4 groups
13
+ const SAMPLE = 600;
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'), expo: join(home, '.sawbuck', 'shape-exposure.json'), svg: join(home, '.sawbuck', 'shape.svg') });
15
+ const title = (s) => s[0].toUpperCase() + s.slice(1);
16
+
17
+ export function prepareShape({ home = homedir() } = {}) {
18
+ const r = prepareRules({ home });
19
+ const p = paths(home);
20
+ const lines = readFileSync(p.rules, 'utf8').split('\n').filter(Boolean);
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]]; }
22
+ const pick = lines.slice(0, SAMPLE);
23
+ writeFileSync(p.input, pick.map((l, i) => `${i + 1}\t${l}`).join('\n') + '\n', { mode: 0o600 });
24
+ writeFileSync(p.expo, JSON.stringify({ ...exposure({ root: join(home, '.claude', 'projects') }), written: writtenRules({ home }) }) + '\n');
25
+ return { input: p.input, lines: pick.length, secrets: r.secrets };
26
+ }
27
+
28
+ export function tally(labels) {
29
+ const counts = Object.fromEntries(SPOKES.map((s) => [s, 0]));
30
+ let none = 0;
31
+ for (const v of Object.values(labels)) { if (v === 'none') none++; else if (v in counts) counts[v]++; }
32
+ const total = SPOKES.reduce((a, s) => a + counts[s], 0);
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]));
34
+ const top = Math.max(1, ...SPOKES.map((s) => counts[s]));
35
+ // the 0 to 99 scale is relative to your own top spoke and compressed so the shape reads: shares carry the real size
36
+ const ratings = Object.fromEntries(SPOKES.map((s) => [s, Math.round(40 + 59 * Math.sqrt(counts[s] / top))]));
37
+ const shares = Object.fromEntries(SPOKES.map((s) => [s, total ? Math.round(100 * counts[s] / total) : 0]));
38
+ return { n: total, none, groups, ratings, shares, spokes: total >= MIN_SPOKES };
39
+ }
40
+
41
+ // What was not said: each spoke we can measure from what the agent did, read against how often you push on it.
42
+ const HIGH_PUSH = 8, HIGH_DOING = 10, HIGH_FAULT = 5; // a fault counts sooner than plain activity
43
+ const SEEN = {
44
+ 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.' },
45
+ 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.' },
46
+ process: { of: 'working', n: 'shipped', says: 'committed, pushed or published', rule: 'Do not commit or push unless I ask.' },
47
+ voice: { of: 'working', n: 'copy', says: 'wrote docs or copy', rule: 'Write docs in plain, short sentences.' },
48
+ visual: { of: 'working', n: 'ui', says: 'changed the interface', rule: 'Show me interface changes before calling them done.' },
49
+ simplicity: { fault: true, of: 'working', n: 'big', says: 'added 200 lines or more', rule: 'Keep changes small. Ask before adding more than 200 lines.' },
50
+ naming: { of: 'working', n: 'renames', says: 'renamed something', rule: 'Ask before renaming anything public.' },
51
+ autonomy: { of: 'working', n: 'asked', says: 'stopped to ask you', rule: 'Keep going on reversible work without asking.' },
52
+ };
53
+ export function patterns(t, c) {
54
+ if (!c || !c.working) return [];
55
+ return Object.entries(SEEN).map(([s, m]) => {
56
+ const pct = Math.round(100 * (c[m.n] || 0) / Math.max(1, c[m.of]));
57
+ const push = t.shares[s], often = pct >= (m.fault ? HIGH_FAULT : HIGH_DOING), pushed = push >= HIGH_PUSH;
58
+ // a fault you keep raising is a fight; activity you raise is just attention, since committing is not a failure
59
+ const has = !!(c.written && c.written[s]);
60
+ const kind = often && pushed ? (m.fault ? 'fighting it' : 'watched') : often ? (has ? 'covered' : 'blind spot') : pushed ? 'handled' : 'quiet';
61
+ return { spoke: s, kind, pct, count: c[m.n] || 0, of: c[m.of], push, says: m.says, rule: m.rule, has };
62
+ });
63
+ }
64
+
65
+ export function shapeSvg(t, found = []) {
66
+ const names = t.spokes ? SPOKES : Object.keys(GROUPS);
67
+ 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)));
68
+ 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)]; };
69
+ const doing = Object.fromEntries(found.map((f) => [f.spoke, f.pct]));
70
+ const topDoing = Math.max(1, ...Object.values(doing));
71
+ const seenPts = t.spokes && found.length ? names.flatMap((n, i) => (n in doing ? [pt(i, 0.4 + 0.59 * Math.sqrt(doing[n] / topDoing))] : [])) : [];
72
+ const ring = (v) => names.map((_, i) => pt(i, v).join(',')).join(' ');
73
+ 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('');
74
+ 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`;
75
+ }
76
+
77
+ export function shapeCli(args, { home = homedir() } = {}) {
78
+ if (args.includes('--prepare')) { const r = prepareShape({ home }); return `🐴 ${r.lines} of your messages are ready in ${r.input}${r.secrets ? `, ${r.secrets} that looked like secrets left out` : ''}.`; }
79
+ const i = args.indexOf('--check');
80
+ if (i === -1) return 'sawbuck shape --prepare | --check <labels.json>';
81
+ const file = args[i + 1];
82
+ if (!file || !existsSync(file)) return '🐴 sawbuck: --check needs the labels file.';
83
+ const m = readFileSync(file, 'utf8').match(/\{[\s\S]*\}/);
84
+ const t = tally(m ? JSON.parse(m[0]) : {});
85
+ if (!t.n) return '🐴 sawbuck: no usable labels in that file.';
86
+ const p = paths(home);
87
+ mkdirSync(p.dir, { recursive: true });
88
+ let prev = null;
89
+ 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]); }
90
+ let c = null; try { c = JSON.parse(readFileSync(p.expo, 'utf8')); } catch {}
91
+ const found = patterns(t, c);
92
+ writeFileSync(p.svg, shapeSvg(t, found));
93
+ const out = [`🐴 your steering shape, from ${t.n} messages that pushed on something (${t.none} did not). What you push on, not a score.`, ''];
94
+ for (const [g, ss] of Object.entries(GROUPS)) {
95
+ const was = prev && prev.groups[g] != null ? ` (was ${prev.groups[g]}%)` : '';
96
+ out.push(` ${g.padEnd(8)} ${String(t.groups[g]).padStart(3)}%${was}`);
97
+ if (t.spokes) out.push(` ${ss.map((s) => `${title(s)} ${t.ratings[s]}`).join(' ')}`);
98
+ }
99
+ if (!t.spokes) out.push('', ` Too few messages for the 12 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
100
+ appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares, ...(c ? { exposure: c } : {}) }) + '\n');
101
+ const fight = found.filter((f) => f.kind === 'fighting it'), blind = found.filter((f) => f.kind === 'blind spot');
102
+ if (fight.length || blind.length) out.push('', ' Patterns to work on:');
103
+ 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}`);
104
+ 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}`);
105
+ const covered = found.filter((f) => f.kind === 'covered');
106
+ 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.`);
107
+ 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.');
108
+ out.push('', ` Card: ${p.svg}`);
109
+ return out.join('\n');
110
+ }