sawbuck 0.1.42 → 0.1.44

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) |
76
74
 
77
75
  ## Give your agent the contract
78
76
 
@@ -113,15 +111,13 @@ jobs:
113
111
 
114
112
  `--fail-on` takes `undocumented` (a bare `# type: ignore` / `# noqa` with no code), `test` (a newly disabled test), `any`, or a number of new suppressions. Exit 1 fails the check; add `--json` for the machine-readable delta. This repo is also a composite action: `uses: sawbuck-ai/sawbuck@v0` with `base` and `fail-on` inputs.
115
113
 
116
- Debt also rises on its own: once attached, the per-turn `context` hook surfaces the live suppression debt on the files your agent is touching when it is the suspect kind (undocumented, a disabled test, or on code that keeps breaking), so you do not have to run anything.
114
+ Debt is on demand: `sawbuck debt`, and `sawbuck context` for the files in hand. To have history added to every turn, set `{"turn":{"context":true}}` in `sawbuck.config.json`. It is off by default because a written rule did the work in every bench, and context spent on each prompt is context lost.
117
115
 
118
116
  ## The engines
119
117
 
120
118
  Sawbuck is thin glue over two roles, each behind an open interface that points in opposite directions:
121
119
 
122
120
  - **the check** : the deterministic outcome gate, a published standard (the [Verified-Outcome Contract](CONTRACT.md)) that others conform to. Reference implementation: **promptwheel**. You plug in *what* to measure (your tests, lint, coverage, an eval); the source re-proof is the fixed part. Embed it with `import { check }`, or check any tool's verdict with `promptwheel conformance`.
123
- - **the memory** : the relevant history for the files you touch, plus the live debt. A socket you can fill. Default provider: **@promptwheel/logbook** (git history as agent memory; also ships an MCP server). Sawbuck reads it through one adapter, so you can *compose in your own* memory: `SAWBUCK_MEMORY=logbook,obsidian` keeps logbook as the git-truth + debt provider and adds your notes, then `sawbuck recall`. Four reference providers ship in `providers/`: **obsidian** (local notes vault; set `OBSIDIAN_VAULT`), **local** (a plain JSON file, zero setup), **claude** (read-only: your project's `CLAUDE.md` / `.claude` memory, already there, nothing to configure), and **mem0** (semantic memory; the one that reaches the network, so it needs a `MEM0_API_KEY` or a `~/.mem0` key file, unlike the offline default; verified against Mem0's v3 API). Any bin that speaks the three contract verbs works; `sawbuck memory-conformance <bin>` checks one. Composed providers are **folder-scoped and isolated by default**: each repo sees only its own memory, never a sibling's. Drop a `.sawbuck-root` marker (or set `SAWBUCK_MEMORY_ROOT`) in a parent folder to let repos under it inherit that folder's shared memory, top down. logbook is the default, not a hard requirement of the idea.
124
-
125
- In one line: the check is a standard we publish for others to meet; the memory is a socket we expose for others to fill. Sawbuck composes the two.
121
+ - **the memory** : the relevant history for the files you touch, plus the live debt, from **@promptwheel/logbook** (git history as agent memory; also ships an MCP server).
126
122
 
127
123
  Apache 2.0.
@@ -0,0 +1,26 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // Write (or refresh) the agent-orienting contract block so a coding agent (or a loop not wired to the
4
+ // `attach --claude` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
5
+ // CLAUDE.md / .cursorrules when present. Idempotent + safe: only the sentinel-delimited block is
6
+ // touched. `--no-agent-block` skips it.
7
+ export function writeAgentBlock(repo) {
8
+ const START = '<!-- sawbuck:contract -->', END = '<!-- /sawbuck:contract -->';
9
+ 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`;
10
+ const targets = ['AGENTS.md', ...['CLAUDE.md', '.cursorrules'].filter((f) => existsSync(join(repo, f)))];
11
+ const done = [];
12
+ for (const f of targets) {
13
+ const p = join(repo, f);
14
+ let text = existsSync(p) ? readFileSync(p, 'utf8') : '';
15
+ const s = text.indexOf(START);
16
+ if (s > -1) {
17
+ const e = text.indexOf(END, s);
18
+ // a section someone wrote inside the block stays: this refreshes our paragraph, never their words
19
+ const kept = e > -1 ? (text.slice(s, e).match(/\n### Standing rules[\s\S]*$/) || [''])[0].replace(/\n+$/, '\n') : '';
20
+ const body = kept ? block.replace(END, `${kept}${END}`) : block;
21
+ text = e > -1 ? text.slice(0, s) + body + text.slice(e + END.length).replace(/^\n+/, '\n') : text.slice(0, s) + body;
22
+ } else text = (text.trim() ? text.replace(/\n*$/, '\n\n') : '') + block;
23
+ try { writeFileSync(p, text); done.push(f); } catch { /* best-effort */ }
24
+ }
25
+ return done;
26
+ }
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 } 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');
@@ -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,20 +5,28 @@ 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 function ensureExcluded(r) {
8
+ export const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
9
+
10
+ // everything the block hides, under any attach mode
11
+ export const EXCLUDED = [...RECORD_DIRS, '.claude/settings.local.json'];
12
+
13
+ // The one writer of the block, in the repository's own exclude file wherever its git dir is (a worktree or
14
+ // submodule keeps it elsewhere). Rewrites to the current set, so a grown set retrofits existing repos; no lines
15
+ // strips it. Returns whether the file changed.
16
+ function writeBlock(r, lines) {
9
17
  try {
10
- // the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
11
18
  const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
12
- if (!rel) return;
19
+ if (!rel) return false;
13
20
  const f = resolve(r, rel);
14
- // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
15
- // 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';
17
21
  const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
18
22
  const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
19
- const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
20
- if (next === body) return;
23
+ const block = lines.length ? `# >>> sawbuck >>>\n${lines.join('\n')}\n# <<< sawbuck <<<\n` : '';
24
+ const next = (block && stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + block;
25
+ if (next === body) return false;
21
26
  mkdirSync(dirname(f), { recursive: true });
22
27
  writeFileSync(f, next);
23
- } catch {}
28
+ return true;
29
+ } catch { return false; }
24
30
  }
31
+ export const ensureExcluded = (r) => writeBlock(r, EXCLUDED);
32
+ export const clearExcluded = (r) => writeBlock(r, []);
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.42",
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.44",
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,14 +34,19 @@
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
43
  "rules.mjs",
44
+ "history.mjs",
45
+ "shape.mjs",
46
+ "exposure.mjs",
47
+ "agent-block.mjs",
44
48
  "exclude.mjs",
49
+ "repo-state.mjs",
45
50
  "contract-cmd.mjs",
46
51
  "bins.mjs",
47
52
  "contract.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/repo-state.mjs ADDED
@@ -0,0 +1,22 @@
1
+ // One read of what Sawbuck has here, shared by the statusline and `sawbuck status`. Files only, no git and no model.
2
+ // state: watching (wired in), ready (checkable, not wired yet), memory (history only), or empty.
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ const WEEK = 7 * 864e5;
7
+
8
+ export function repoState(repo) {
9
+ const record = (f) => { try { return JSON.parse(readFileSync(join(repo, '.sawbuck', f), 'utf8')); } catch { return null; } };
10
+ const has = (f) => existsSync(join(repo, f));
11
+ const results = record('results.json') || {};
12
+ const caught = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
13
+ const hasConfig = has('promptwheel.config.json');
14
+ const armed = hasConfig || has('package.json');
15
+ const state = has('.sawbuck/state.json') ? 'watching' : armed ? 'ready' : (has('LOGBOOK.md') || has('events.jsonl')) ? 'memory' : '';
16
+ // the reins engine's file; reins does the model work, never sawbuck. A rate nobody has updated for a week is
17
+ // history, not a reading, and the judge is off unless someone turned it on.
18
+ const s = record('steer.json');
19
+ const live = s && s.scored && !(s.at && Date.now() - Date.parse(s.at) > WEEK);
20
+ const steer = live ? { rate: Math.round(((s.ema != null ? s.ema : s.steeringRate) || 0) * 100), scored: s.scored } : null;
21
+ return { results, caught, hasConfig, armed, state, steer };
22
+ }
package/rules.mjs CHANGED
@@ -1,47 +1,32 @@
1
1
  // Find the rules you keep enforcing and never wrote down. `prepare` gathers what you typed to Claude Code, on this
2
2
  // machine, with copies and likely secrets removed. The agent proposes rules from it, each with quotes. `check` proves
3
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 { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
+ import { readHistory, typedText } from './history.mjs';
7
8
 
8
9
  const MAX_LINES = 3000;
9
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;
10
11
  const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
11
12
  const paths = (home) => ({ dir: join(home, '.sawbuck'), input: join(home, '.sawbuck', 'rule-input.txt') });
12
13
 
13
- function typed(message) {
14
- const c = message && message.content;
15
- if (typeof c === 'string') return c;
16
- if (Array.isArray(c) && c.length && c.every((b) => b.type === 'text')) return c.map((b) => b.text).join(' ');
17
- return '';
18
- }
19
-
20
14
  export function prepareRules({ home = homedir() } = {}) {
21
- const root = join(home, '.claude', 'projects');
22
15
  const found = [];
23
16
  const seen = new Set();
24
17
  let secrets = 0;
25
- let dirs = []; try { dirs = readdirSync(root); } catch {}
26
- for (const d of dirs) {
27
- let files = []; try { files = readdirSync(join(root, d)).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
28
- for (const f of files) {
29
- const p = join(root, d, f);
30
- let text = ''; try { text = readFileSync(p, 'utf8'); } catch { continue; }
31
- for (const line of text.split('\n')) {
32
- if (!line.includes('"type":"user"')) continue;
33
- let e; try { e = JSON.parse(line); } catch { continue; }
34
- if (e.type !== 'user' || e.isMeta || e.isSidechain) continue;
35
- const t = typed(e.message).replace(/\s+/g, ' ').trim();
36
- if (t.length < 8 || t.length > 400 || /^[<[]/.test(t) || t.startsWith('Caveat:') || t.startsWith('This session is being continued')) continue;
37
- const k = norm(t).slice(0, 120);
38
- if (seen.has(k)) continue; // a resumed session copies its history
39
- seen.add(k);
40
- if (SECRET.test(t)) { secrets++; continue; }
41
- found.push({ t, at: e.timestamp || '' });
42
- }
43
- }
44
- }
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
+ });
45
30
  found.sort((a, b) => String(a.at).localeCompare(String(b.at)));
46
31
  const lines = found.slice(-MAX_LINES).map((x) => x.t);
47
32
  const { dir, input } = paths(home);
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,8 +70,22 @@ 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 {} };
75
+ // the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp (written by
76
+ // `sawbuck turn --hook`), never a constant that would spend it for good
77
+ const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
78
+ // Send the turn back, at most enforce.maxBlocks times per prompt, then let it end with a warning: it can never
79
+ // loop, because the harness's own block cap resets on every tool call.
80
+ const sendBack = (idKey, countKey, reason, stillOpen) => {
81
+ const max = +(cfg.enforce?.maxBlocks) || 3;
82
+ if (st[idKey] !== promptId) { st[idKey] = promptId; st[countKey] = 0; }
83
+ const spent = (st[countKey] || 0) >= max;
84
+ if (!spent) st[countKey] = (st[countKey] || 0) + 1;
85
+ saveState();
86
+ process.stdout.write(JSON.stringify(spent ? { systemMessage: `Sawbuck stopped blocking after ${max} tries: ${stillOpen}` } : { decision: 'block', reason }));
87
+ process.exit(0);
88
+ };
76
89
 
77
90
  // 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
78
91
  // tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
@@ -113,17 +126,12 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
113
126
  } else if (st.lastViolation) { delete st.lastViolation; }
114
127
  saveState();
115
128
  if (hits.length) {
116
- const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
117
- // the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp
118
- // (written by `sawbuck turn --hook`), never a constant that would spend the budget for good
119
- const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
120
- if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
121
129
  const desc = hits.map((h) => `"${h.name}"${h.reason ? ' (' + h.reason + ')' : ''}: ${h.files.join(', ')}${h.committed ? ' [this rule is in the committed contract; editing promptwheel.config.json in the working tree does not lift it]' : ''}`).join('; ');
122
130
  const spared = leftAlone.length ? ` Edits that were already there before this prompt were left out of this check and must stay as they are: ${leftAlone.join(', ')}.` : '';
123
131
  const before = (st.lastViolation && st.lastViolation.before) || {};
124
132
  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
133
  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.' : '';
134
+ const accept = committedHit ? ' To accept this change on purpose, change the rule in the contract or revert the path.' : '';
127
135
  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
136
  // one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
129
137
  // a block must not replace a gamed verdict that is still standing.
@@ -135,15 +143,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
135
143
  writeRec(r);
136
144
  logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
137
145
  }
138
- if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
139
- st.mandateBlocks = (st.mandateBlocks || 0) + 1;
140
- 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}` }));
142
- process.exit(0);
143
- }
144
- saveState();
145
- process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: protected paths are still changed (${desc}).` }));
146
- process.exit(0);
146
+ sendBack('mandatePromptId', 'mandateBlocks', `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}`, `protected paths are still changed (${desc}).`);
147
147
  }
148
148
  }
149
149
 
@@ -153,21 +153,9 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
153
153
  // bound a tool-using agent on its own. Opt out with SAWBUCK_NO_ENFORCE=1 (falls back to a flag).
154
154
  const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
155
155
  if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
156
- const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
157
- const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
158
- if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
159
156
  st.shownTotal = total;
160
157
  const where = rec.latest.file;
161
- if ((st.blockCount || 0) < MAX_BLOCKS) {
162
- st.blockCount = (st.blockCount || 0) + 1;
163
- saveState();
164
- const reason = `Sawbuck: the win on ${where} was gamed. It came from editing the test or config, not the source, so it did not survive a source-only re-run. The turn is not done: fix the source so the metric passes on its own, then finish.`;
165
- process.stdout.write(JSON.stringify({ decision: 'block', reason }));
166
- process.exit(0);
167
- }
168
- saveState(); // budget spent: let the turn end, but warn loudly so it never loops forever
169
- process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: the win on ${where} is still gamed and needs a real source fix.` }));
170
- process.exit(0);
158
+ sendBack('blockPromptId', 'blockCount', `Sawbuck: the win on ${where} was gamed. It came from editing the test or config, not the source, so it did not survive a source-only re-run. The turn is not done: fix the source so the metric passes on its own, then finish.`, `the win on ${where} is still gamed and needs a real source fix.`);
171
159
  }
172
160
 
173
161
  // 3b) THE LOCK AT TURN LEVEL: tell the person the moment a turn changes the repo's structure or its resolved
@@ -191,8 +179,8 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE && st.turnBaseline && st.turnBaselin
191
179
 
192
180
  // 4) otherwise, surface a catch on a fresh catch or on cadence (a flag; never blocks)
193
181
  const freshCatch = total > (st.shownTotal || 0);
194
- const cadenceHit = everyTurns > 0 && st.turns % everyTurns === 0;
195
- if (freshCatch || (cadenceHit && !onlyOnCatch)) {
182
+ const cadenceHit = st.turns % EVERY_TURNS === 0;
183
+ if (freshCatch || cadenceHit) {
196
184
  const out = node(join(HERE, 'dispatch.mjs'), ['--repo', repo]);
197
185
  if (out.trim()) process.stdout.write(out.endsWith('\n') ? out : out + '\n');
198
186
  st.shownTotal = total;
package/sawbuck.mjs CHANGED
@@ -13,10 +13,12 @@
13
13
  // proves the repo came back clean; `doctor` checks the attachment still resolves.
14
14
 
15
15
  import { execFileSync } from 'node:child_process';
16
- import { erasedBlock } from './rewrites.mjs';
16
+ import { erasedBlock, erasedCommits } from './rewrites.mjs';
17
17
  import { steersReport } from './steers.mjs';
18
18
  import { rulesCli } from './rules.mjs';
19
- import { ensureExcluded } from './exclude.mjs';
19
+ import { shapeCli } from './shape.mjs';
20
+ import { ensureExcluded, clearExcluded, EXCLUDED, RECORD_DIRS } from './exclude.mjs';
21
+ import { repoState } from './repo-state.mjs';
20
22
  import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
21
23
  import { basename, join, dirname, resolve } from 'node:path';
22
24
  import { fileURLToPath } from 'node:url';
@@ -47,7 +49,6 @@ const BLOCK_RE = new RegExp(`\\n?${S_START}[\\s\\S]*?${S_END}\\n?`, 'g');
47
49
  // plus the logbook's root working-set (ledger cache, digests, annotation planes). It is
48
50
  // memory, not the user's source, a user who wants LOGBOOK.md committed still can explicitly
49
51
  // (exclude only affects UNtracked files).
50
- const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
51
52
 
52
53
  const argv = process.argv.slice(2);
53
54
  const cmd = argv[0];
@@ -70,14 +71,12 @@ Usage:
70
71
  fastest way to see it work. Every promptwheel run flag passes through.
71
72
  Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
72
73
  sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
73
- Write the contract (promptwheel.config.json), tuned and explained by what your
74
+ Customize the contract (promptwheel.config.json), with notes from what your
74
75
  git history shows actually breaks, plus a short contract guide in AGENTS.md
75
76
  (--no-agent-block to skip). Wizard in a terminal, non-interactive in a pipe/CI (or --yes).
76
- sawbuck attach [repo] Enforce the contract on every commit (a git post-commit hook)
77
- --claude instead on every Claude Code turn (project hooks in .claude/settings.local.json)
78
- --shared with --claude, write the tracked .claude/settings.json (team-shared, visible in git)
79
- --user user-level Claude hooks that follow your cwd (attach once, every project)
80
- --dry-run print the exact footprint it would leave, and write nothing
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
81
80
  sawbuck detach [repo] [--claude | --user] [--all] [--verify] [--purge]
82
81
  Remove it and restore the repo (no flag: whatever is attached here); --verify
83
82
  proves it came back clean; --purge also deletes the record (.sawbuck/)
@@ -87,9 +86,9 @@ Usage:
87
86
  The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
88
87
  shows only what a branch adds; --fail-on exits nonzero (a CI gate)
89
88
  sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
90
- sawbuck frame [repo] The contract's hard rules (mandates, inherited ones included) as one compact block;
89
+ sawbuck frame [repo] The contract's protected paths, inherited ones included, as one compact block;
91
90
  silent without a contract (--speak explains)
92
- sawbuck turn [repo] frame + context in one block: what attach --claude runs on every prompt
91
+ sawbuck turn [repo] What attach --claude runs on a prompt: the frame, once a session and after a compaction
93
92
 
94
93
  sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
95
94
  sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
@@ -97,13 +96,11 @@ Usage:
97
96
  sawbuck contract Sign this repo: tests, tripwires, protected paths, and the architecture lock (--dry-run to preview)
98
97
  sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
99
98
  sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
99
+ sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
100
+ sawbuck shape --check F Draw your steering shape from the labels, keeping only the totals (--png for an image)
100
101
  sawbuck steers What you keep correcting, from the plugin's scratchpad on this machine (--json)
101
- sawbuck statusline One-line segment for your status line (🐴 watching | N caught | steer %)
102
+ sawbuck statusline One-line segment for your status line (🐴 watching | N caught)
102
103
 
103
- sawbuck recall [repo] Related notes for your changes from extra memory providers
104
- (SAWBUCK_MEMORY=logbook,obsidian + OBSIDIAN_VAULT), alongside context
105
- sawbuck memory-conformance <bin>
106
- Check a provider bin speaks the Memory Contract (recall/debt/record)
107
104
  sawbuck --help | --version
108
105
 
109
106
  Engines: promptwheel (the check) + @promptwheel/logbook (memory). Zero-LLM, offline, free.`;
@@ -185,7 +182,7 @@ switch (cmd) {
185
182
  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); }
186
183
  if (json) { out(JSON.stringify(rep)); process.exit(code); }
187
184
  const m = (rep.metrics || [])[0];
188
- 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]' : ''}`);
185
+ 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]' : ''}`);
189
186
  const win = (rep.metrics || []).find((x) => x.status === 'improved' && !x.tripwire);
190
187
  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).`);
191
188
  else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
@@ -235,13 +232,14 @@ switch (cmd) {
235
232
  // cannot forget them. Silent without a contract; --speak explains instead.
236
233
  const o = frameBlock(repoRoot());
237
234
  if (o) out(o);
238
- 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.');
235
+ 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.');
239
236
  break;
240
237
  }
241
238
  case 'turn': {
242
- // the ONE per-prompt hook: the contract frame (forward: what must hold) and the relevant history
243
- // (backward: what broke here before) in a single block, one process per prompt. Silent when
244
- // there is nothing to say. Wired by `attach --claude` on UserPromptSubmit and after a compaction.
239
+ // the ONE per-prompt hook. The agent gets the contract frame once a session and again after a compaction: one
240
+ // written statement held in every bench, so saying it on each prompt only spends context. The relevant history
241
+ // 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.
245
243
  // the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
246
244
  // subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
247
245
  const top = gitTop(repoArg());
@@ -256,8 +254,8 @@ switch (cmd) {
256
254
  // a file still carrying the content the last violation flagged is never baselined; and the
257
255
  // baseline's commit does not advance past a flagged commit until a clean stop or a commit the
258
256
  // person made between turns that no longer carries the flagged content.
259
- let hookEvent = '';
260
- if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
257
+ let hookEvent = '', session = '', framed = false;
258
+ if (flags.has('--hook') && !process.stdin.isTTY) { try { const h = JSON.parse(readFileSync(0, 'utf8') || '{}'); hookEvent = String(h.hook_event_name || ''); session = String(h.session_id || ''); } catch {} }
261
259
  if (hookEvent === 'UserPromptSubmit' && top) {
262
260
  try {
263
261
  ensureExcluded(repo); // before the first write: under user-level hooks nothing else has done it yet
@@ -288,11 +286,16 @@ switch (cmd) {
288
286
  st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo), rules: mandatesInForce(repo) };
289
287
  }
290
288
  }
289
+ framed = !!session && st.framedSession === session;
290
+ if (session) st.framedSession = session;
291
291
  st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
292
292
  mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
293
293
  } catch { /* the frame never fails a prompt */ }
294
294
  }
295
- const o = [frameBlock(repo), contextBlock(repo, false)].filter(Boolean).join('\n');
295
+ let history = false;
296
+ try { history = JSON.parse(readFileSync(join(repo, 'sawbuck.config.json'), 'utf8')).turn?.context === true; } catch {}
297
+ if (!history && top) erasedCommits(repo); // the ledger outlives the reflog whether or not it is printed
298
+ const o = [framed ? '' : frameBlock(repo), history ? contextBlock(repo, false) : ''].filter(Boolean).join('\n');
296
299
  if (o) out(o);
297
300
  break;
298
301
  }
@@ -359,6 +362,7 @@ switch (cmd) {
359
362
  case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
360
363
  case 'where': { whereAttached(); break; }
361
364
  case 'rules': { out(rulesCli(rest)); break; }
365
+ case 'shape': { out(shapeCli(rest)); break; }
362
366
  case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
363
367
  case 'statusline': {
364
368
  // compact status-line segment for the cwd repo. Never throws; fast.
@@ -367,22 +371,16 @@ switch (cmd) {
367
371
  if (!repo) process.stdout.write('🐴 sawbuck · no repo here');
368
372
  if (repo) {
369
373
  const name = basename(repo);
370
- let caught = 0;
371
- try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
374
+ const { caught, state, steer } = repoState(repo);
372
375
  // the last verdict from the activity feed (includes verified passes), so you see it working each turn
373
376
  let last = '';
374
377
  try { const L = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').trim().split('\n'); const e = JSON.parse(L[L.length - 1]); last = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' }[e.kind] || ''; } catch {}
375
- // one state model, shared with the Reins band: watching (wired in) / ready (checkable, not wired yet) / memory (history only)
376
- const active = existsSync(join(repo, '.sawbuck', 'state.json'));
377
- const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
378
- const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
379
- const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
378
+ const label = caught > 0 ? `${caught} caught` : state;
380
379
  const seg = label ? `🐴 sawbuck · ${label}` : '';
381
380
  const named = label ? `🐴 sawbuck · ${name} · ${label}` : ''; // which repo it detected, in the default line
382
381
  const base = named ? (last ? `${named} · ${last}` : named) : '';
383
382
  // live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
384
- let st = '';
385
- try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) { const p = Math.round(((sj.ema != null ? sj.ema : sj.steeringRate) || 0) * 100); st = `${p >= 50 ? '✊' : '🐎'} steer ${p}%`; } } catch {}
383
+ const st = steer ? `${steer.rate >= 50 ? '✊' : '🐎'} steer ${steer.rate}%` : '';
386
384
  // configurable template: SAWBUCK_STATUSLINE picks which segments show and in what order
387
385
  // (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
388
386
  // so you never get a dangling separator. Unset = the default composition. Mix in other
@@ -404,7 +402,7 @@ switch (cmd) {
404
402
  default: die(`unknown command '${cmd}'\n\n${help}`);
405
403
  }
406
404
 
407
- // --- the per-turn block: frame (forward, from contract.mjs) + context (backward) ---------------
405
+ // --- context: the ranked history for the files in hand, on demand ---------------------------
408
406
  // the read face: ranked relevant history for what you're working on right now. Silent when there is
409
407
  // nothing to say, so it is safe on every UserPromptSubmit (that hook's stdout is injected into context;
410
408
  // "nothing notable" every turn would be noise). `speak` forces a reply for the explicit command.
@@ -453,26 +451,9 @@ function delFile(p) { try { if (existsSync(p)) unlinkSync(p); } catch {} }
453
451
  function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
454
452
 
455
453
  // --- .git/info/exclude: keep the worktree clean without touching .gitignore --
456
- // Rewrites a single marked block to the union of excludes the current
457
- // attachments need. Never deletes the file (an empty one is git's own default).
454
+ // Any attachment keeps the block, the last one out takes it. Returns whether the file changed.
458
455
  function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
459
- function syncExclude(repo, manifest) {
460
- const need = new Set();
461
- const a = manifest.attachments || {};
462
- if (Object.keys(a).length) for (const d of RECORD_DIRS) need.add(d); // any attach → the check may write the record dirs
463
- if (a.claude) need.add('.claude/settings.local.json'); // default claude settings are local; hide them too
464
- const lines = [...need];
465
- const f = excludeFile(repo);
466
- let body = (existsSync(f) ? readFileSync(f, 'utf8') : '').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
467
- if (lines.length) {
468
- if (body && !body.endsWith('\n')) body += '\n';
469
- body += `${S_START}\n${lines.join('\n')}\n${S_END}\n`;
470
- mkdirSync(dirname(f), { recursive: true });
471
- writeFileSync(f, body);
472
- } else if (existsSync(f)) {
473
- writeFileSync(f, body); // strip our block, leave the rest
474
- }
475
- }
456
+ function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
476
457
 
477
458
  // --- git post-commit --------------------------------------------------------
478
459
  // The hook prefers a `sawbuck` on PATH (so it survives a reinstall/upgrade) and
@@ -495,7 +476,7 @@ function wireGit(repo) {
495
476
  if (dry) {
496
477
  out(dryText([
497
478
  `${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
498
- `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')})`,
479
+ `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
499
480
  `create ${manifestPath(repo)} (attach receipt)`,
500
481
  ]));
501
482
  return;
@@ -513,7 +494,8 @@ function unwireGit(repo) {
513
494
  const pc = gitPath(repo, 'hooks/post-commit');
514
495
  const m = readManifest(manifestPath(repo));
515
496
  const hasReceipt = !!m.attachments?.git;
516
- if (!existsSync(pc) && !hasReceipt) { out('sawbuck: nothing to detach'); return; }
497
+ // signing with no attach still hides the record, so a repo with no receipt can have a block to take back
498
+ if (!existsSync(pc) && !hasReceipt) { out(syncExclude(repo, m) ? `sawbuck: removed the record lines from ${excludeFile(repo)}` : 'sawbuck: nothing to detach'); return; }
517
499
  if (existsSync(pc)) {
518
500
  const stripped = readFileSync(pc, 'utf8').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
519
501
  const created = m.attachments?.git?.pc?.created;
@@ -556,8 +538,14 @@ function wireClaude(repo, opts) {
556
538
  const fileExisted = existsSync(f);
557
539
  let cfg = {};
558
540
  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.`); } }
559
- const ctx = user ? `${nodeCmd()} ${JSON.stringify(SELF)} turn --hook` : `${nodeCmd()} ${JSON.stringify(SELF)} turn --hook ${JSON.stringify(repo)}`;
560
- const chk = user ? `${nodeCmd()} ${JSON.stringify(SELF)} __hook --by claude --stop` : `${nodeCmd()} ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
541
+ // run through npx, the installed path lives in a cache that can be cleared, and a missing hook fails quietly and
542
+ // leaves the wall off: fall back to fetching the same version
543
+ const run = (args) => {
544
+ const direct = `${nodeCmd()} ${JSON.stringify(SELF)} ${args}`;
545
+ return process.platform !== 'win32' && SELF.includes('/_npx/') ? `if [ -f ${JSON.stringify(SELF)} ]; then ${direct}; else npx -y sawbuck@${version} ${args}; fi` : direct;
546
+ };
547
+ const ctx = run(user ? 'turn --hook' : `turn --hook ${JSON.stringify(repo)}`);
548
+ const chk = run(user ? '__hook --by claude --stop' : `__hook --repo ${JSON.stringify(repo)} --by claude --stop`);
561
549
  // attached already? "already attached" only when every hook of THIS version is present; an older
562
550
  // attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
563
551
  let upgrading = false;
@@ -569,7 +557,7 @@ function wireClaude(repo, opts) {
569
557
  if (dry) {
570
558
  out(dryText([
571
559
  `${upgrading ? 'update' : fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
572
- user ? null : `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')}${shared ? '' : ', .claude/settings.local.json'})`,
560
+ user ? null : `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
573
561
  `create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
574
562
  ].filter(Boolean)));
575
563
  return;
@@ -580,8 +568,8 @@ function wireClaude(repo, opts) {
580
568
  cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
581
569
  };
582
570
  put('UserPromptSubmit', ctx);
583
- // right after a compaction the summary has just dropped detail: re-impress the contract frame and the
584
- // relevant history there too (SessionStart with the `compact` source injects stdout into context).
571
+ // right after a compaction the summary has just dropped detail: re-impress the contract frame there too
572
+ // (SessionStart with the `compact` source injects stdout into context).
585
573
  put('SessionStart', ctx, 'compact');
586
574
  put('Stop', chk);
587
575
  mkdirSync(dir, { recursive: true });
@@ -611,6 +599,7 @@ function wireClaude(repo, opts) {
611
599
  regAdd(repo, mode);
612
600
  out(upgrading ? `sawbuck: updated the ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f} to this version`
613
601
  : `sawbuck: wired ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f}`);
602
+ 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.');
614
603
  if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
615
604
  else if (!shared) out(' (settings.local.json is gitignored; use --shared to commit the hooks for your team)');
616
605
  }
@@ -684,6 +673,8 @@ function purgeRecord(repo) {
684
673
  let n = 0;
685
674
  for (const d of RECORD_DIRS) {
686
675
  const p = join(repo, d);
676
+ // a committed LOGBOOK.md or JOURNEY.md is the person's file, not our record
677
+ try { if (execFileSync('git', ['-C', repo, 'ls-files', '--', d], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()) continue; } catch {}
687
678
  try { if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); n++; out(`sawbuck: purged ${p}`); } } catch {}
688
679
  }
689
680
  const outcomes = join(repo, '.promptwheel', 'outcomes.jsonl'); // the rest of .promptwheel/ is the signed contract: yours to keep or delete
@@ -725,7 +716,7 @@ function doctorAll() {
725
716
 
726
717
  // --- registry: know and remove every attachment -----------------------------
727
718
  function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
728
- function regRead() { try { return JSON.parse(readFileSync(regPath(), 'utf8')); } catch { return { repos: [] }; } }
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
729
720
  function regWrite(r) {
730
721
  const f = regPath();
731
722
  if (!r.repos.length) { delFile(f); rmIfEmptyDir(dirname(f)); return; }
package/shape.mjs ADDED
@@ -0,0 +1,134 @@
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, rmSync, copyFileSync } from 'node:fs';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { join, dirname } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ import { prepareRules } from './rules.mjs';
9
+ import { exposure, writtenRules } from './exposure.mjs';
10
+
11
+ export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['direction', 'scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
12
+ const SPOKES = Object.values(GROUPS).flat();
13
+ const MIN_SPOKES = 200; // below this, spokes are noise: show the 4 groups
14
+ 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') });
16
+ const title = (s) => s[0].toUpperCase() + s.slice(1);
17
+
18
+ export function prepareShape({ home = homedir() } = {}) {
19
+ const r = prepareRules({ home });
20
+ const p = paths(home);
21
+ const lines = readFileSync(p.rules, 'utf8').split('\n').filter(Boolean);
22
+ 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
+ const pick = lines.slice(0, SAMPLE);
24
+ 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
+ return { input: p.input, lines: pick.length, secrets: r.secrets };
27
+ }
28
+
29
+ export function tally(labels) {
30
+ const counts = Object.fromEntries(SPOKES.map((s) => [s, 0]));
31
+ let none = 0;
32
+ for (const v of Object.values(labels)) { if (v === 'none') none++; else if (v in counts) counts[v]++; }
33
+ const total = SPOKES.reduce((a, s) => a + counts[s], 0);
34
+ 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
+ const top = Math.max(1, ...SPOKES.map((s) => counts[s]));
36
+ // the 0 to 99 scale is relative to your own top spoke and compressed so the shape reads: shares carry the real size
37
+ const ratings = Object.fromEntries(SPOKES.map((s) => [s, Math.round(40 + 59 * Math.sqrt(counts[s] / top))]));
38
+ const shares = Object.fromEntries(SPOKES.map((s) => [s, total ? Math.round(100 * counts[s] / total) : 0]));
39
+ return { n: total, none, groups, ratings, shares, spokes: total >= MIN_SPOKES };
40
+ }
41
+
42
+ // 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 = []) {
69
+ const names = t.spokes ? SPOKES : Object.keys(GROUPS);
70
+ 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
+ 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
+ const ring = (v) => names.map((_, i) => pt(i, v).join(',')).join(' ');
76
+ 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`;
78
+ }
79
+
80
+ // A PNG of the card, with what is already on the machine: sawbuck ships no image library. A snap browser sees neither
81
+ // a hidden folder nor the real /tmp, so it works in a plain folder in home and the result is copied over.
82
+ function pngCard(svg) {
83
+ const png = svg.replace(/\.svg$/, '.png');
84
+ rmSync(png, { force: true });
85
+ const run = (bin, args) => { try { execFileSync(bin, args, { stdio: 'ignore', timeout: 30000 }); return true; } catch { return false; } };
86
+ if (run('rsvg-convert', ['-w', '1200', '-o', png, svg]) && existsSync(png)) return png;
87
+ const tmp = join(dirname(dirname(svg)), `sawbuck-shape-${process.pid}`);
88
+ try {
89
+ mkdirSync(tmp, { recursive: true });
90
+ const a = join(tmp, 'shape.svg'), b = join(tmp, 'shape.png');
91
+ copyFileSync(svg, a);
92
+ for (const bin of ['google-chrome', 'chromium', 'chromium-browser']) {
93
+ if (run(bin, ['--headless', '--disable-gpu', '--hide-scrollbars', '--window-size=1000,1000', `--screenshot=${b}`, `file://${a}`]) && existsSync(b)) { copyFileSync(b, png); return png; }
94
+ }
95
+ } catch {} finally { rmSync(tmp, { recursive: true, force: true }); }
96
+ return '';
97
+ }
98
+
99
+ export function shapeCli(args, { home = homedir() } = {}) {
100
+ 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` : ''}.`; }
101
+ const i = args.indexOf('--check');
102
+ if (i === -1) return 'sawbuck shape --prepare | --check <labels.json>';
103
+ const file = args[i + 1];
104
+ if (!file || !existsSync(file)) return '🐴 sawbuck: --check needs the labels file.';
105
+ const m = readFileSync(file, 'utf8').match(/\{[\s\S]*\}/);
106
+ const t = tally(m ? JSON.parse(m[0]) : {});
107
+ if (!t.n) return '🐴 sawbuck: no usable labels in that file.';
108
+ const p = paths(home);
109
+ mkdirSync(p.dir, { recursive: true });
110
+ let prev = null;
111
+ 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));
115
+ 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
+ for (const [g, ss] of Object.entries(GROUPS)) {
117
+ const was = prev && prev.groups[g] != null ? ` (was ${prev.groups[g]}%)` : '';
118
+ out.push(` ${g.padEnd(8)} ${String(t.groups[g]).padStart(3)}%${was}`);
119
+ if (t.spokes) out.push(` ${ss.map((s) => `${title(s)} ${t.ratings[s]}`).join(' ')}`);
120
+ }
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.');
131
+ out.push('', ` Card: ${p.svg}`);
132
+ 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
+ return out.join('\n');
134
+ }
package/status.mjs CHANGED
@@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process';
12
12
  import { readFileSync, existsSync } from 'node:fs';
13
13
  import { join, dirname, resolve, basename } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { repoState } from './repo-state.mjs';
15
16
 
16
17
  const HERE = dirname(fileURLToPath(import.meta.url));
17
18
  const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
@@ -27,26 +28,19 @@ const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive:
27
28
  // --- 1) the outcome record: what the check has done here -------------------
28
29
  // results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
29
30
  // the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
30
- const results = readJSON(join(repo, '.sawbuck', 'results.json')) || {};
31
+ const { results, caught: catchesAllTime, state: kind, steer, armed, hasConfig } = repoState(repo);
31
32
  const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
32
33
  const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0, mandate: 0 };
33
34
  for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
34
35
  const seen = feed.length;
35
- const catchesAllTime = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
36
36
  const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
37
37
  const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
38
- // the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file; the
38
+ // the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file, in repoState; the
39
39
  // recent-window average over roughly the last twenty scored turns, as the band and statusline show it)
40
- const steerRec = readJSON(join(repo, '.sawbuck', 'steer.json'));
41
- const steer = steerRec && steerRec.scored ? { rate: Math.round(((steerRec.ema != null ? steerRec.ema : steerRec.steeringRate) || 0) * 100), scored: steerRec.scored } : null;
42
40
 
43
- // --- 2) the armed state: what it can measure here (mirror of statusline) ---
41
+ // --- 2) the armed state: what it can measure here (shared with the statusline) ---
44
42
  const isGit = !!existsSync(join(repo, '.git'));
45
- const hasConfig = existsSync(join(repo, 'promptwheel.config.json'));
46
- const watching = existsSync(join(repo, '.sawbuck', 'state.json'));
47
- const armed = hasConfig || existsSync(join(repo, 'package.json'));
48
- const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
49
- const state = watching ? 'watching' : armed ? 'ready' : mem ? 'memory' : 'unarmed';
43
+ const state = kind || 'unarmed';
50
44
 
51
45
  // --- 3) the live debt ledger: best-effort, bounded -------------------------
52
46
  // A full-repo HEAD grep + gating; fast on normal repos, can be heavy on a giant one. Bounded so
package/steers.mjs CHANGED
@@ -37,7 +37,7 @@ export function steersReport({ json = false, home } = {}) {
37
37
  const groups = groupSteers(steers);
38
38
  const repeated = groups.filter((g) => g.n >= 3 && g.sessions >= 2);
39
39
  if (json) return JSON.stringify({ steers: steers.length, scored: stats.scored, unscored: stats.lost, repeated });
40
- if (!steers.length && !stats.scored) return '🐴 sawbuck: no steers recorded yet. They appear here as you work with the plugin\'s judge on.';
40
+ if (!steers.length && !stats.scored) return '🐴 sawbuck: no steers recorded. The plugin\'s live judge is off by default; /sawbuck-reins:rules reads your history instead.';
41
41
  const out = [`🐴 ${steers.length} steers kept, from ${stats.scored} replies scored${stats.lost ? ` (${stats.lost} could not be scored, so this undercounts)` : ''}. Measured, not estimated.`];
42
42
  if (repeated.length) {
43
43
  out.push('', 'You keep saying:');