sawbuck 0.1.41 → 0.1.42
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/package.json +2 -1
- package/rules.mjs +82 -0
- package/sawbuck.mjs +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.42",
|
|
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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"sawbuck.mjs",
|
|
41
41
|
"rewrites.mjs",
|
|
42
42
|
"steers.mjs",
|
|
43
|
+
"rules.mjs",
|
|
43
44
|
"exclude.mjs",
|
|
44
45
|
"contract-cmd.mjs",
|
|
45
46
|
"bins.mjs",
|
package/rules.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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 { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
const MAX_LINES = 3000;
|
|
9
|
+
const SECRET = /(sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9]{20,}|github_pat_\w{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[\w-]{10,}|eyJ[\w-]{20,}\.[\w-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY|\b[a-f0-9]{40,}\b|(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)/i;
|
|
10
|
+
const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
11
|
+
const paths = (home) => ({ dir: join(home, '.sawbuck'), input: join(home, '.sawbuck', 'rule-input.txt') });
|
|
12
|
+
|
|
13
|
+
function typed(message) {
|
|
14
|
+
const c = message && message.content;
|
|
15
|
+
if (typeof c === 'string') return c;
|
|
16
|
+
if (Array.isArray(c) && c.length && c.every((b) => b.type === 'text')) return c.map((b) => b.text).join(' ');
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function prepareRules({ home = homedir() } = {}) {
|
|
21
|
+
const root = join(home, '.claude', 'projects');
|
|
22
|
+
const found = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
let secrets = 0;
|
|
25
|
+
let dirs = []; try { dirs = readdirSync(root); } catch {}
|
|
26
|
+
for (const d of dirs) {
|
|
27
|
+
let files = []; try { files = readdirSync(join(root, d)).filter((f) => f.endsWith('.jsonl')); } catch { continue; }
|
|
28
|
+
for (const f of files) {
|
|
29
|
+
const p = join(root, d, f);
|
|
30
|
+
let text = ''; try { text = readFileSync(p, 'utf8'); } catch { continue; }
|
|
31
|
+
for (const line of text.split('\n')) {
|
|
32
|
+
if (!line.includes('"type":"user"')) continue;
|
|
33
|
+
let e; try { e = JSON.parse(line); } catch { continue; }
|
|
34
|
+
if (e.type !== 'user' || e.isMeta || e.isSidechain) continue;
|
|
35
|
+
const t = typed(e.message).replace(/\s+/g, ' ').trim();
|
|
36
|
+
if (t.length < 8 || t.length > 400 || /^[<[]/.test(t) || t.startsWith('Caveat:') || t.startsWith('This session is being continued')) continue;
|
|
37
|
+
const k = norm(t).slice(0, 120);
|
|
38
|
+
if (seen.has(k)) continue; // a resumed session copies its history
|
|
39
|
+
seen.add(k);
|
|
40
|
+
if (SECRET.test(t)) { secrets++; continue; }
|
|
41
|
+
found.push({ t, at: e.timestamp || '' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
found.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
46
|
+
const lines = found.slice(-MAX_LINES).map((x) => x.t);
|
|
47
|
+
const { dir, input } = paths(home);
|
|
48
|
+
mkdirSync(dir, { recursive: true });
|
|
49
|
+
writeFileSync(input, lines.join('\n') + '\n', { mode: 0o600 });
|
|
50
|
+
return { input, lines: lines.length, secrets };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function checkRules(file, { home = homedir() } = {}) {
|
|
54
|
+
const lines = readFileSync(paths(home).input, 'utf8').split('\n').map(norm);
|
|
55
|
+
const raw = readFileSync(file, 'utf8');
|
|
56
|
+
const m = raw.match(/\[[\s\S]*\]/);
|
|
57
|
+
const list = m ? JSON.parse(m[0]) : [];
|
|
58
|
+
const kept = [], dropped = [];
|
|
59
|
+
for (const r of list) {
|
|
60
|
+
const quotes = (r.quotes || []).filter((q) => { const n = norm(String(q)); return n.length >= 8 && lines.some((l) => l.includes(n)); });
|
|
61
|
+
(quotes.length >= 2 ? kept : dropped).push({ rule: String(r.rule), quotes });
|
|
62
|
+
}
|
|
63
|
+
return { kept, dropped };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function rulesCli(args, { home = homedir() } = {}) {
|
|
67
|
+
if (args.includes('--prepare')) {
|
|
68
|
+
const r = prepareRules({ home });
|
|
69
|
+
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.`;
|
|
70
|
+
}
|
|
71
|
+
const i = args.indexOf('--check');
|
|
72
|
+
if (i > -1) {
|
|
73
|
+
const file = args[i + 1];
|
|
74
|
+
if (!file || !existsSync(file) || !statSync(file).isFile()) return '🐴 sawbuck: --check needs the candidates file.';
|
|
75
|
+
if (!existsSync(paths(home).input)) return '🐴 sawbuck: run sawbuck rules --prepare first.';
|
|
76
|
+
const { kept, dropped } = checkRules(file, { home });
|
|
77
|
+
const out = [`🐴 ${kept.length} rules have at least 2 quotes that are really yours${dropped.length ? `, ${dropped.length} dropped` : ''}.`];
|
|
78
|
+
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)}"`); });
|
|
79
|
+
return out.join('\n');
|
|
80
|
+
}
|
|
81
|
+
return 'sawbuck rules --prepare | --check <candidates.json>';
|
|
82
|
+
}
|
package/sawbuck.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
16
|
import { erasedBlock } from './rewrites.mjs';
|
|
17
17
|
import { steersReport } from './steers.mjs';
|
|
18
|
+
import { rulesCli } from './rules.mjs';
|
|
18
19
|
import { ensureExcluded } from './exclude.mjs';
|
|
19
20
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
|
|
20
21
|
import { basename, join, dirname, resolve } from 'node:path';
|
|
@@ -94,6 +95,8 @@ 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
|
|
97
100
|
sawbuck steers What you keep correcting, from the plugin's scratchpad on this machine (--json)
|
|
98
101
|
sawbuck statusline One-line segment for your status line (🐴 watching | N caught | steer %)
|
|
99
102
|
|
|
@@ -355,6 +358,7 @@ switch (cmd) {
|
|
|
355
358
|
case 'trace': { traceRepo(repoRoot()); break; }
|
|
356
359
|
case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
|
|
357
360
|
case 'where': { whereAttached(); break; }
|
|
361
|
+
case 'rules': { out(rulesCli(rest)); break; }
|
|
358
362
|
case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
|
|
359
363
|
case 'statusline': {
|
|
360
364
|
// compact status-line segment for the cwd repo. Never throws; fast.
|