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