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 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.38",
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.13"
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') { out(help); process.exit(0); }
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) => a === '--dry-run')], { stdio: 'inherit' }); }
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(`🐴 ✗ regression: a guarded metric got worse (${(rep.metrics || []).filter((x) => x.guard && !x.ok).map((x) => x.name).join(', ') || 'a guard'}).`);
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
+ }