sawbuck 0.1.40 → 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/README.md CHANGED
@@ -84,7 +84,7 @@ 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>"`.
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. If your tests can't run in CI, the check says so and doesn't fail the job, and protected paths are still enforced. 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
88
 
89
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.
90
90
 
package/contract-cmd.mjs CHANGED
@@ -19,7 +19,9 @@ const noCi = process.argv.includes('--no-ci');
19
19
  // a new contract starts by watching: everyday change shows as a note. Protected paths, install scripts and sources
20
20
  // outside the registry still fail from day one. --strict holds them all, and an existing contract is left as it is.
21
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.
22
+ // Tests need their dependencies, so an npm lockfile gets an install, with no scripts. Elsewhere tests may not run in
23
+ // CI, so inconclusive does not fail the job: protected paths are checked either way, and a gamed or failed verdict still fails.
24
+ const workflow = (npm) => `# Written by sawbuck contract. Checks every pull request against the contract in promptwheel.config.json.
23
25
  name: Sawbuck
24
26
  on: pull_request
25
27
  permissions: { contents: read, pull-requests: write }
@@ -29,7 +31,11 @@ jobs:
29
31
  steps:
30
32
  - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
31
33
  with: { fetch-depth: 0 }
32
- - uses: promptwheel-ai/promptwheel@v0
34
+ ${npm ? ` - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
35
+ with: { node-version: 22 }
36
+ - run: npm ci --ignore-scripts
37
+ ` : ''} - uses: promptwheel-ai/promptwheel@v0
38
+ with: { inconclusive: pass }
33
39
  `;
34
40
  const cfgPath = join(repo, 'promptwheel.config.json');
35
41
  const run = (args, cwd, stdio = ['ignore', 'pipe', 'pipe']) => execFileSync(process.execPath, args, { cwd, encoding: 'utf8', stdio });
@@ -73,11 +79,11 @@ try {
73
79
  let remote = ''; try { remote = execFileSync('git', ['-C', repo, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {}
74
80
  const realTests = !(cfg.metrics || []).some((m) => /set your test command/.test(String(m.cmd)));
75
81
  let ci = '';
76
- if (!noCi && /github\.com/.test(remote) && realTests && !existsSync(wf)) { mkdirSync(dirname(wf), { recursive: true }); writeFileSync(wf, WORKFLOW); ci = wf; }
82
+ if (!noCi && /github\.com/.test(remote) && realTests && !existsSync(wf)) { mkdirSync(dirname(wf), { recursive: true }); writeFileSync(wf, workflow(existsSync(join(repo, 'package-lock.json')))); ci = wf; }
77
83
  const locked = existsSync(join(repo, 'promptwheel.lock.json'));
78
84
  if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
79
85
  const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
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');
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');
81
87
  }
82
88
  } finally { rmSync(scratch, { recursive: true, force: true }); }
83
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.40",
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",
@@ -34,12 +34,13 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@promptwheel/logbook": "0.9.10",
37
- "promptwheel": "0.7.14"
37
+ "promptwheel": "0.7.15"
38
38
  },
39
39
  "files": [
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.