sawbuck 0.1.38 → 0.1.39
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 +2 -0
- package/contract-cmd.mjs +26 -2
- package/package.json +3 -2
- package/sawbuck.mjs +16 -3
- package/steers.mjs +48 -0
package/README.md
CHANGED
|
@@ -84,6 +84,8 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
|
|
|
84
84
|
Run `sawbuck context` first to see what broke in these files before (reverts, weakened tests,
|
|
85
85
|
fragile spots). Never skip, weaken, or delete a test to go green.
|
|
86
86
|
|
|
87
|
+
**After you sign.** A new contract starts by watching: protected paths, new install scripts and sources outside the registry block from day one, and new dependencies and structure changes show as notes. `sawbuck contract --strict` blocks them all. When something fails, the check says what changed in words. To accept it on a pull request, someone with write access comments `sawbuck-allow: <reason>` and re-runs the check; the reason stays on the record. An agent signed in as that person could post the same comment, so branch protection and review still matter. Locally, use `--allow "<reason>"`.
|
|
88
|
+
|
|
87
89
|
**Rewritten history.** Logbook reads git, so a squash or a rebase takes a revert with it. `sawbuck context` also reads the reflog for commits no branch reaches any more and shows them for the files in hand, and once attached it keeps them in `.sawbuck/rewritten.jsonl` after the reflog expires. It stays local: a clone does not get it, nothing older than the reflog comes back, and work that was never committed is not in git at all. Your steering is separate: `reins` reads your agent transcripts, not git.
|
|
88
90
|
|
|
89
91
|
**Tested claims.** Every claim above, and every limit, is a test in [`test/claims.test.mjs`](test/claims.test.mjs), run on a clean account.
|
package/contract-cmd.mjs
CHANGED
|
@@ -14,6 +14,23 @@ import { PW } from './bins.mjs';
|
|
|
14
14
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const repo = process.argv[2] || process.cwd();
|
|
16
16
|
const dry = process.argv.includes('--dry-run');
|
|
17
|
+
const strict = process.argv.includes('--strict');
|
|
18
|
+
const noCi = process.argv.includes('--no-ci');
|
|
19
|
+
// a new contract starts by watching: everyday change shows as a note. Protected paths, install scripts and sources
|
|
20
|
+
// outside the registry still fail from day one. --strict holds them all, and an existing contract is left as it is.
|
|
21
|
+
const REPORT = new Set(['dependency_entries', 'lock_drift', 'pins_changed', 'unpinned_execution', 'install_policy_gaps']);
|
|
22
|
+
const WORKFLOW = `# Written by sawbuck contract. Checks every pull request against the contract in promptwheel.config.json.
|
|
23
|
+
name: Sawbuck
|
|
24
|
+
on: pull_request
|
|
25
|
+
permissions: { contents: read, pull-requests: write }
|
|
26
|
+
jobs:
|
|
27
|
+
contract:
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
31
|
+
with: { fetch-depth: 0 }
|
|
32
|
+
- uses: promptwheel-ai/promptwheel@v0
|
|
33
|
+
`;
|
|
17
34
|
const cfgPath = join(repo, 'promptwheel.config.json');
|
|
18
35
|
const run = (args, cwd, stdio = ['ignore', 'pipe', 'pipe']) => execFileSync(process.execPath, args, { cwd, encoding: 'utf8', stdio });
|
|
19
36
|
|
|
@@ -34,7 +51,7 @@ try {
|
|
|
34
51
|
const names = (l) => new Set((l || []).map((x) => x && x.name));
|
|
35
52
|
const hasM = names(cfg.mandates), hasG = names(cfg.metrics);
|
|
36
53
|
cfg.mandates = [...(cfg.mandates || []), ...preset.mandates.filter((m) => !hasM.has(m.name))];
|
|
37
|
-
cfg.metrics = [...(cfg.metrics || []), ...preset.metrics.filter((m) => !hasG.has(m.name))];
|
|
54
|
+
cfg.metrics = [...(cfg.metrics || []), ...preset.metrics.filter((m) => !hasG.has(m.name)).map((m) => (!strict && REPORT.has(m.name) ? { ...m, report: true } : m))];
|
|
38
55
|
cfg.evaluatorPaths = [...new Set([...(cfg.evaluatorPaths || []), ...(preset.evaluatorPaths || [])])];
|
|
39
56
|
cfg.allowTrailer = false;
|
|
40
57
|
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
|
|
@@ -50,10 +67,17 @@ try {
|
|
|
50
67
|
if (next !== text) writeFileSync(f, next);
|
|
51
68
|
}
|
|
52
69
|
ensureExcluded(repo);
|
|
70
|
+
// CI goes in here, before the rules take hold: afterwards the contract itself would refuse an agent the file.
|
|
71
|
+
// Only for a GitHub repo with a real test command: a placeholder would fail every pull request.
|
|
72
|
+
const wf = join(repo, '.github', 'workflows', 'sawbuck.yml');
|
|
73
|
+
let remote = ''; try { remote = execFileSync('git', ['-C', repo, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {}
|
|
74
|
+
const realTests = !(cfg.metrics || []).some((m) => /set your test command/.test(String(m.cmd)));
|
|
75
|
+
let ci = '';
|
|
76
|
+
if (!noCi && /github\.com/.test(remote) && realTests && !existsSync(wf)) { mkdirSync(dirname(wf), { recursive: true }); writeFileSync(wf, WORKFLOW); ci = wf; }
|
|
53
77
|
const locked = existsSync(join(repo, 'promptwheel.lock.json'));
|
|
54
78
|
if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
|
|
55
79
|
const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
|
|
56
|
-
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` : 'sawbuck: already signed, nothing changed.\n');
|
|
80
|
+
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.\n' : ''}` : 'sawbuck: already signed, nothing changed.\n');
|
|
57
81
|
}
|
|
58
82
|
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
59
83
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
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 support in beta.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -34,11 +34,12 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@promptwheel/logbook": "0.9.10",
|
|
37
|
-
"promptwheel": "0.7.
|
|
37
|
+
"promptwheel": "0.7.14"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"sawbuck.mjs",
|
|
41
41
|
"rewrites.mjs",
|
|
42
|
+
"steers.mjs",
|
|
42
43
|
"exclude.mjs",
|
|
43
44
|
"contract-cmd.mjs",
|
|
44
45
|
"bins.mjs",
|
package/sawbuck.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
16
|
import { erasedBlock } from './rewrites.mjs';
|
|
17
|
+
import { steersReport } from './steers.mjs';
|
|
17
18
|
import { ensureExcluded } from './exclude.mjs';
|
|
18
19
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
|
|
19
20
|
import { basename, join, dirname, resolve } from 'node:path';
|
|
@@ -93,6 +94,7 @@ Usage:
|
|
|
93
94
|
sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
|
|
94
95
|
sawbuck where List every repo Sawbuck is attached to
|
|
95
96
|
sawbuck contract Sign this repo: tests, tripwires, protected paths, and the architecture lock (--dry-run to preview)
|
|
97
|
+
sawbuck steers What you keep correcting, from the plugin's scratchpad on this machine (--json)
|
|
96
98
|
sawbuck statusline One-line segment for your status line (🐴 watching | N caught | steer %)
|
|
97
99
|
|
|
98
100
|
sawbuck recall [repo] Related notes for your changes from extra memory providers
|
|
@@ -116,13 +118,22 @@ function gitDir(repo) { try { return resolve(repo, execFileSync('git', ['-C', re
|
|
|
116
118
|
const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
|
|
117
119
|
|
|
118
120
|
if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
|
|
119
|
-
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
121
|
+
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
122
|
+
out(flags.has('--all') ? help : `sawbuck ${version}: sign your agent to a contract
|
|
123
|
+
|
|
124
|
+
sawbuck contract sign this repo: protected paths, your tests, CI
|
|
125
|
+
sawbuck check verify your latest change right now
|
|
126
|
+
sawbuck status what it has caught here
|
|
127
|
+
|
|
128
|
+
sawbuck help --all everything else`);
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
120
131
|
|
|
121
132
|
switch (cmd) {
|
|
122
133
|
case 'contract': {
|
|
123
134
|
const top = gitTop(repoArg());
|
|
124
135
|
if (!top) usage(`${repoArg()} is not a git repository (a contract signs one; run it inside your project)`);
|
|
125
|
-
try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) =>
|
|
136
|
+
try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => ['--dry-run', '--strict', '--no-ci'].includes(a))], { stdio: 'inherit' }); }
|
|
126
137
|
catch (e) { process.exit(e.status || 1); }
|
|
127
138
|
break;
|
|
128
139
|
}
|
|
@@ -177,9 +188,10 @@ switch (cmd) {
|
|
|
177
188
|
else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
|
|
178
189
|
else if (rep.verdict === 'gamed') out('🐴 🚩 gamed: a win here came from editing the test, config, or grader, not the source. It does NOT survive a source-only re-run, so it does not count.');
|
|
179
190
|
else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a hard rule in the contract was broken, however green the rest is.');
|
|
180
|
-
else if (rep.verdict === 'fail') out(
|
|
191
|
+
else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
|
|
181
192
|
else out('🐴 ≈ inconclusive: the check ran but could not measure your metric (it did not run in isolation). Point it at your real test command: `sawbuck init`, or set setup/linkDirs in promptwheel.config.json.');
|
|
182
193
|
if ((rep.metrics || []).some((x) => x.heldByTestEdit)) out('🐴 ⚠ your tests changed too, and the old tests fail on this source. Fine for an intended change. Otherwise check the tests were not weakened: a green that stays green is not proved.');
|
|
194
|
+
for (const x of rep.explained || []) out(`🐴 ${x.noted ? 'note' : 'needs a look'}: ${x.what}${x.items ? ': ' + x.items.join(', ') : ''}${!x.noted && x.allowable ? '. If you meant this: --allow "<reason>", or on a pull request comment sawbuck-allow: <reason>' : ''}`);
|
|
183
195
|
if (rep.gate && rep.gate.changed) out(`🐴 ⚠ the gate definition changed in this change (fingerprint ${rep.gate.fingerprint.base} -> ${rep.gate.fingerprint.head}): a metric, guard, evaluatorPath or mandate was added, removed or edited.`);
|
|
184
196
|
process.exit(code);
|
|
185
197
|
}
|
|
@@ -343,6 +355,7 @@ switch (cmd) {
|
|
|
343
355
|
case 'trace': { traceRepo(repoRoot()); break; }
|
|
344
356
|
case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
|
|
345
357
|
case 'where': { whereAttached(); break; }
|
|
358
|
+
case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
|
|
346
359
|
case 'statusline': {
|
|
347
360
|
// compact status-line segment for the cwd repo. Never throws; fast.
|
|
348
361
|
try {
|
package/steers.mjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The steer scratchpad, for the person: what you keep correcting, read from Claude Code's plugin store on this
|
|
2
|
+
// machine. Reads only. Counts here are measured; nothing is projected. The grouping matches the plugin's.
|
|
3
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
const STOP = new Set(['the', 'a', 'an', 'to', 'of', 'and', 'or', 'it', 'this', 'that', 'do', 'not', 'dont', 'use', 'be', 'in', 'on', 'for', 'with', 'is', 'are', 'your', 'you']);
|
|
8
|
+
export const steerKey = (n) => String(n).toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter((w) => w && !STOP.has(w)).slice(0, 3).sort().join(' ');
|
|
9
|
+
|
|
10
|
+
export function readSteers(home = homedir()) {
|
|
11
|
+
const dir = join(home, '.claude', 'plugins', 'store');
|
|
12
|
+
const out = { steers: [], stats: { scored: 0, lost: 0 } };
|
|
13
|
+
let files = []; try { files = readdirSync(dir).filter((f) => f.startsWith('sawbuck-reins_') && f.endsWith('.json')); } catch { return out; }
|
|
14
|
+
for (const f of files) {
|
|
15
|
+
try {
|
|
16
|
+
const d = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
17
|
+
if (Array.isArray(d.steers)) out.steers.push(...d.steers);
|
|
18
|
+
if (d.steerStats) { out.stats.scored += d.steerStats.scored || 0; out.stats.lost += d.steerStats.lost || 0; }
|
|
19
|
+
} catch {}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function groupSteers(list) {
|
|
25
|
+
const g = new Map();
|
|
26
|
+
for (const e of list) {
|
|
27
|
+
const key = steerKey(e.nudge); if (!key) continue;
|
|
28
|
+
const x = g.get(key) || { key, nudge: e.nudge, n: 0, sessions: new Set(), repos: new Set() };
|
|
29
|
+
x.n++; x.sessions.add(e.session); x.repos.add(e.repo || 'no repo'); g.set(key, x);
|
|
30
|
+
}
|
|
31
|
+
return [...g.values()].map((x) => ({ key: x.key, nudge: x.nudge, n: x.n, sessions: x.sessions.size, repos: [...x.repos] }))
|
|
32
|
+
.sort((a, b) => b.n - a.n || b.repos.length - a.repos.length);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function steersReport({ json = false, home } = {}) {
|
|
36
|
+
const { steers, stats } = readSteers(home);
|
|
37
|
+
const groups = groupSteers(steers);
|
|
38
|
+
const repeated = groups.filter((g) => g.n >= 3 && g.sessions >= 2);
|
|
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.';
|
|
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
|
+
if (repeated.length) {
|
|
43
|
+
out.push('', 'You keep saying:');
|
|
44
|
+
for (const g of repeated.slice(0, 10)) out.push(` ${String(g.n).padStart(3)}x ${g.nudge} (${g.repos.join(', ')})`);
|
|
45
|
+
out.push('', 'If one of these is a rule, write it down: a protected path in promptwheel.config.json, or a line in CLAUDE.md.');
|
|
46
|
+
} else out.push('', 'Nothing repeats yet: a steer shows here after 3 times across 2 sessions.');
|
|
47
|
+
return out.join('\n');
|
|
48
|
+
}
|