sawbuck 0.1.43 → 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
@@ -70,7 +70,7 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
70
70
  | `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
71
71
  | `sawbuck context` | the ranked relevant history for the current change |
72
72
  | `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
73
- | `sawbuck statusline` | a one-line status-line segment (🐴 watching / N caught / steer %) |
73
+ | `sawbuck statusline` | a one-line status-line segment (🐴 watching / N caught) |
74
74
 
75
75
  ## Give your agent the contract
76
76
 
@@ -111,15 +111,13 @@ jobs:
111
111
 
112
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.
113
113
 
114
- 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.
115
115
 
116
116
  ## The engines
117
117
 
118
118
  Sawbuck is thin glue over two roles, each behind an open interface that points in opposite directions:
119
119
 
120
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`.
121
- - **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.
122
-
123
- 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).
124
122
 
125
123
  Apache 2.0.
package/agent-block.mjs CHANGED
@@ -1,15 +1,5 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
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
3
  // Write (or refresh) the agent-orienting contract block so a coding agent (or a loop not wired to the
14
4
  // `attach --claude` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
15
5
  // CLAUDE.md / .cursorrules when present. Idempotent + safe: only the sentinel-delimited block is
@@ -17,16 +7,19 @@ export function standingRules(home = homedir()) {
17
7
  export function writeAgentBlock(repo) {
18
8
  const START = '<!-- sawbuck:contract -->', END = '<!-- /sawbuck:contract -->';
19
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`;
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
10
  const targets = ['AGENTS.md', ...['CLAUDE.md', '.cursorrules'].filter((f) => existsSync(join(repo, f)))];
23
11
  const done = [];
24
12
  for (const f of targets) {
25
13
  const p = join(repo, f);
26
14
  let text = existsSync(p) ? readFileSync(p, 'utf8') : '';
27
15
  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; }
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;
30
23
  try { writeFileSync(p, text); done.push(f); } catch { /* best-effort */ }
31
24
  }
32
25
  return done;
package/contract-cmd.mjs CHANGED
@@ -4,7 +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
+ import { writeAgentBlock } from './agent-block.mjs';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, mkdirSync } from 'node:fs';
10
10
  import { join, dirname, resolve } from 'node:path';
@@ -86,7 +86,7 @@ try {
86
86
  const locked = existsSync(join(repo, 'promptwheel.lock.json'));
87
87
  if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
88
88
  const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
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');
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'}${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');
90
90
  }
91
91
  } finally { rmSync(scratch, { recursive: true, force: true }); }
92
92
 
package/exclude.mjs CHANGED
@@ -7,20 +7,26 @@ const sh = (r, ...a) => { try { return execFileSync('git', ['-C', r, ...a], { en
7
7
 
8
8
  export const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
9
9
 
10
- export function ensureExcluded(r) {
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) {
11
17
  try {
12
- // the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
13
18
  const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
14
- if (!rel) return;
19
+ if (!rel) return false;
15
20
  const f = resolve(r, rel);
16
- // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
17
- // the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
18
- const WANT = `# >>> sawbuck >>>\n${[...RECORD_DIRS, '.claude/settings.local.json'].join('\n')}\n# <<< sawbuck <<<\n`;
19
21
  const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
20
22
  const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
21
- const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
22
- 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;
23
26
  mkdirSync(dirname(f), { recursive: true });
24
27
  writeFileSync(f, next);
25
- } catch {}
28
+ return true;
29
+ } catch { return false; }
26
30
  }
31
+ export const ensureExcluded = (r) => writeBlock(r, EXCLUDED);
32
+ export const clearExcluded = (r) => writeBlock(r, []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
4
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",
@@ -46,6 +46,7 @@
46
46
  "exposure.mjs",
47
47
  "agent-block.mjs",
48
48
  "exclude.mjs",
49
+ "repo-state.mjs",
49
50
  "contract-cmd.mjs",
50
51
  "bins.mjs",
51
52
  "contract.mjs",
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/sawbuck-hook.mjs CHANGED
@@ -72,6 +72,20 @@ st.turns += 1;
72
72
  // 2) the record of what the check has charted (read above, after any re-run)
73
73
  const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) + (rec.mandates || 0) : 0;
74
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
+ };
75
89
 
76
90
  // 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
77
91
  // tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
@@ -112,11 +126,6 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
112
126
  } else if (st.lastViolation) { delete st.lastViolation; }
113
127
  saveState();
114
128
  if (hits.length) {
115
- const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
116
- // the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp
117
- // (written by `sawbuck turn --hook`), never a constant that would spend the budget for good
118
- const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
119
- if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
120
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('; ');
121
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(', ')}.` : '';
122
131
  const before = (st.lastViolation && st.lastViolation.before) || {};
@@ -134,15 +143,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
134
143
  writeRec(r);
135
144
  logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
136
145
  }
137
- if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
138
- st.mandateBlocks = (st.mandateBlocks || 0) + 1;
139
- saveState();
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}` }));
141
- process.exit(0);
142
- }
143
- saveState();
144
- process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: protected paths are still changed (${desc}).` }));
145
- 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}).`);
146
147
  }
147
148
  }
148
149
 
@@ -152,21 +153,9 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
152
153
  // bound a tool-using agent on its own. Opt out with SAWBUCK_NO_ENFORCE=1 (falls back to a flag).
153
154
  const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
154
155
  if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
155
- const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
156
- const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
157
- if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
158
156
  st.shownTotal = total;
159
157
  const where = rec.latest.file;
160
- if ((st.blockCount || 0) < MAX_BLOCKS) {
161
- st.blockCount = (st.blockCount || 0) + 1;
162
- saveState();
163
- 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.`;
164
- process.stdout.write(JSON.stringify({ decision: 'block', reason }));
165
- process.exit(0);
166
- }
167
- saveState(); // budget spent: let the turn end, but warn loudly so it never loops forever
168
- 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.` }));
169
- 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.`);
170
159
  }
171
160
 
172
161
  // 3b) THE LOCK AT TURN LEVEL: tell the person the moment a turn changes the repo's structure or its resolved
package/sawbuck.mjs CHANGED
@@ -13,11 +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
19
  import { shapeCli } from './shape.mjs';
20
- import { ensureExcluded, RECORD_DIRS } from './exclude.mjs';
20
+ import { ensureExcluded, clearExcluded, EXCLUDED, RECORD_DIRS } from './exclude.mjs';
21
+ import { repoState } from './repo-state.mjs';
21
22
  import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
22
23
  import { basename, join, dirname, resolve } from 'node:path';
23
24
  import { fileURLToPath } from 'node:url';
@@ -73,11 +74,9 @@ Usage:
73
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/)
@@ -89,7 +88,7 @@ Usage:
89
88
  sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
90
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
@@ -98,14 +97,10 @@ Usage:
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
100
99
  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
100
+ sawbuck shape --check F Draw your steering shape from the labels, keeping only the totals (--png for an image)
102
101
  sawbuck steers What you keep correcting, from the plugin's scratchpad on this machine (--json)
103
- sawbuck statusline One-line segment for your status line (🐴 watching | N caught | steer %)
102
+ sawbuck statusline One-line segment for your status line (🐴 watching | N caught)
104
103
 
105
- sawbuck recall [repo] Related notes for your changes from extra memory providers
106
- (SAWBUCK_MEMORY=logbook,obsidian + OBSIDIAN_VAULT), alongside context
107
- sawbuck memory-conformance <bin>
108
- Check a provider bin speaks the Memory Contract (recall/debt/record)
109
104
  sawbuck --help | --version
110
105
 
111
106
  Engines: promptwheel (the check) + @promptwheel/logbook (memory). Zero-LLM, offline, free.`;
@@ -241,9 +236,10 @@ switch (cmd) {
241
236
  break;
242
237
  }
243
238
  case 'turn': {
244
- // the ONE per-prompt hook: the contract frame (forward: what must hold) and the relevant history
245
- // (backward: what broke here before) in a single block, one process per prompt. Silent when
246
- // there is nothing to say. Wired by `attach --claude` on UserPromptSubmit and after a compaction.
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.
247
243
  // the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
248
244
  // subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
249
245
  const top = gitTop(repoArg());
@@ -258,8 +254,8 @@ switch (cmd) {
258
254
  // a file still carrying the content the last violation flagged is never baselined; and the
259
255
  // baseline's commit does not advance past a flagged commit until a clean stop or a commit the
260
256
  // person made between turns that no longer carries the flagged content.
261
- let hookEvent = '';
262
- if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
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 {} }
263
259
  if (hookEvent === 'UserPromptSubmit' && top) {
264
260
  try {
265
261
  ensureExcluded(repo); // before the first write: under user-level hooks nothing else has done it yet
@@ -290,11 +286,16 @@ switch (cmd) {
290
286
  st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo), rules: mandatesInForce(repo) };
291
287
  }
292
288
  }
289
+ framed = !!session && st.framedSession === session;
290
+ if (session) st.framedSession = session;
293
291
  st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
294
292
  mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
295
293
  } catch { /* the frame never fails a prompt */ }
296
294
  }
297
- 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');
298
299
  if (o) out(o);
299
300
  break;
300
301
  }
@@ -370,22 +371,16 @@ switch (cmd) {
370
371
  if (!repo) process.stdout.write('🐴 sawbuck · no repo here');
371
372
  if (repo) {
372
373
  const name = basename(repo);
373
- let caught = 0;
374
- try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
374
+ const { caught, state, steer } = repoState(repo);
375
375
  // the last verdict from the activity feed (includes verified passes), so you see it working each turn
376
376
  let last = '';
377
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 {}
378
- // one state model, shared with the Reins band: watching (wired in) / ready (checkable, not wired yet) / memory (history only)
379
- const active = existsSync(join(repo, '.sawbuck', 'state.json'));
380
- const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
381
- const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
382
- const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
378
+ const label = caught > 0 ? `${caught} caught` : state;
383
379
  const seg = label ? `🐴 sawbuck · ${label}` : '';
384
380
  const named = label ? `🐴 sawbuck · ${name} · ${label}` : ''; // which repo it detected, in the default line
385
381
  const base = named ? (last ? `${named} · ${last}` : named) : '';
386
382
  // live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
387
- let st = '';
388
- try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) { const p = Math.round(((sj.ema != null ? sj.ema : sj.steeringRate) || 0) * 100); st = `${p >= 50 ? '✊' : '🐎'} steer ${p}%`; } } catch {}
383
+ const st = steer ? `${steer.rate >= 50 ? '✊' : '🐎'} steer ${steer.rate}%` : '';
389
384
  // configurable template: SAWBUCK_STATUSLINE picks which segments show and in what order
390
385
  // (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
391
386
  // so you never get a dangling separator. Unset = the default composition. Mix in other
@@ -407,7 +402,7 @@ switch (cmd) {
407
402
  default: die(`unknown command '${cmd}'\n\n${help}`);
408
403
  }
409
404
 
410
- // --- the per-turn block: frame (forward, from contract.mjs) + context (backward) ---------------
405
+ // --- context: the ranked history for the files in hand, on demand ---------------------------
411
406
  // the read face: ranked relevant history for what you're working on right now. Silent when there is
412
407
  // nothing to say, so it is safe on every UserPromptSubmit (that hook's stdout is injected into context;
413
408
  // "nothing notable" every turn would be noise). `speak` forces a reply for the explicit command.
@@ -456,26 +451,9 @@ function delFile(p) { try { if (existsSync(p)) unlinkSync(p); } catch {} }
456
451
  function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
457
452
 
458
453
  // --- .git/info/exclude: keep the worktree clean without touching .gitignore --
459
- // Rewrites a single marked block to the union of excludes the current
460
- // attachments need. Never deletes the file (an empty one is git's own default).
454
+ // Any attachment keeps the block, the last one out takes it. Returns whether the file changed.
461
455
  function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
462
- function syncExclude(repo, manifest) {
463
- const need = new Set();
464
- const a = manifest.attachments || {};
465
- if (Object.keys(a).length) for (const d of RECORD_DIRS) need.add(d); // any attach → the check may write the record dirs
466
- if (a.claude) need.add('.claude/settings.local.json'); // default claude settings are local; hide them too
467
- const lines = [...need];
468
- const f = excludeFile(repo);
469
- let body = (existsSync(f) ? readFileSync(f, 'utf8') : '').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
470
- if (lines.length) {
471
- if (body && !body.endsWith('\n')) body += '\n';
472
- body += `${S_START}\n${lines.join('\n')}\n${S_END}\n`;
473
- mkdirSync(dirname(f), { recursive: true });
474
- writeFileSync(f, body);
475
- } else if (existsSync(f)) {
476
- writeFileSync(f, body); // strip our block, leave the rest
477
- }
478
- }
456
+ function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
479
457
 
480
458
  // --- git post-commit --------------------------------------------------------
481
459
  // The hook prefers a `sawbuck` on PATH (so it survives a reinstall/upgrade) and
@@ -498,7 +476,7 @@ function wireGit(repo) {
498
476
  if (dry) {
499
477
  out(dryText([
500
478
  `${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
501
- `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')})`,
479
+ `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
502
480
  `create ${manifestPath(repo)} (attach receipt)`,
503
481
  ]));
504
482
  return;
@@ -516,7 +494,8 @@ function unwireGit(repo) {
516
494
  const pc = gitPath(repo, 'hooks/post-commit');
517
495
  const m = readManifest(manifestPath(repo));
518
496
  const hasReceipt = !!m.attachments?.git;
519
- 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; }
520
499
  if (existsSync(pc)) {
521
500
  const stripped = readFileSync(pc, 'utf8').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
522
501
  const created = m.attachments?.git?.pc?.created;
@@ -578,7 +557,7 @@ function wireClaude(repo, opts) {
578
557
  if (dry) {
579
558
  out(dryText([
580
559
  `${upgrading ? 'update' : fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
581
- user ? null : `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')}${shared ? '' : ', .claude/settings.local.json'})`,
560
+ user ? null : `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
582
561
  `create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
583
562
  ].filter(Boolean)));
584
563
  return;
@@ -589,8 +568,8 @@ function wireClaude(repo, opts) {
589
568
  cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
590
569
  };
591
570
  put('UserPromptSubmit', ctx);
592
- // right after a compaction the summary has just dropped detail: re-impress the contract frame and the
593
- // relevant history there too (SessionStart with the `compact` source injects stdout into context).
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).
594
573
  put('SessionStart', ctx, 'compact');
595
574
  put('Stop', chk);
596
575
  mkdirSync(dir, { recursive: true });
package/shape.mjs CHANGED
@@ -1,8 +1,9 @@
1
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
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
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';
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';
6
7
  import { homedir } from 'node:os';
7
8
  import { prepareRules } from './rules.mjs';
8
9
  import { exposure, writtenRules } from './exposure.mjs';
@@ -39,6 +40,7 @@ export function tally(labels) {
39
40
  }
40
41
 
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
42
44
  const HIGH_PUSH = 8, HIGH_DOING = 10, HIGH_FAULT = 5; // a fault counts sooner than plain activity
43
45
  const SEEN = {
44
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.' },
@@ -57,7 +59,8 @@ export function patterns(t, c) {
57
59
  const push = t.shares[s], often = pct >= (m.fault ? HIGH_FAULT : HIGH_DOING), pushed = push >= HIGH_PUSH;
58
60
  // a fault you keep raising is a fight; activity you raise is just attention, since committing is not a failure
59
61
  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';
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';
61
64
  return { spoke: s, kind, pct, count: c[m.n] || 0, of: c[m.of], push, says: m.says, rule: m.rule, has };
62
65
  });
63
66
  }
@@ -74,6 +77,25 @@ export function shapeSvg(t, found = []) {
74
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`;
75
78
  }
76
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
+
77
99
  export function shapeCli(args, { home = homedir() } = {}) {
78
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` : ''}.`; }
79
101
  const i = args.indexOf('--check');
@@ -104,7 +126,9 @@ export function shapeCli(args, { home = homedir() } = {}) {
104
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}`);
105
127
  const covered = found.filter((f) => f.kind === 'covered');
106
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}.`);
107
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.');
108
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.'); }
109
133
  return out.join('\n');
110
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:');