sawbuck 0.1.46 → 0.1.48
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/CONTRACT.md +9 -9
- package/README.md +53 -33
- package/bins.mjs +1 -1
- package/contract-cmd.mjs +20 -10
- package/debt.mjs +26 -11
- package/exclude.mjs +5 -4
- package/history.mjs +4 -1
- package/init.mjs +7 -6
- package/memory.mjs +5 -13
- package/package.json +4 -3
- package/pipe.mjs +2 -0
- package/rank.mjs +2 -3
- package/reflect.mjs +30 -21
- package/repo-state.mjs +21 -4
- package/rules.mjs +10 -2
- package/sawbuck-hook.mjs +2 -7
- package/sawbuck.mjs +360 -139
- package/shape.mjs +96 -12
- package/status.mjs +14 -6
- package/steers.mjs +1 -1
package/rank.mjs
CHANGED
|
@@ -77,11 +77,10 @@ const normMult = (p, kind) => {
|
|
|
77
77
|
// The "slider": debt rises on its own, but gently. A disabled test or an undocumented suppression
|
|
78
78
|
// on a touched file always surfaces; a file that is churning (hot / reverted) surfaces any debt;
|
|
79
79
|
// a stable file's routine documented suppressions stay quiet until they pile past DEBT_MIN. Tune
|
|
80
|
-
// with SAWBUCK_DEBT_MIN. This is how debt
|
|
80
|
+
// with SAWBUCK_DEBT_MIN. This is how debt shows up in `sawbuck context` with no command of its own.
|
|
81
81
|
const DEBT_MIN = Math.max(1, +(process.env.SAWBUCK_DEBT_MIN) || 6);
|
|
82
82
|
|
|
83
|
-
// Still-live filter + ambient debt, via the
|
|
84
|
-
// capability), scoped to the touched paths so the per-turn hook stays cheap. A suppression on a path
|
|
83
|
+
// Still-live filter + ambient debt, via the Logbook adapter, scoped to the touched paths so the read stays cheap. A suppression on a path
|
|
85
84
|
// now CLEAN of suppressions is churn, not current risk (the study: AutoMapper carries 4 live
|
|
86
85
|
// `[Ignore]` vs ~158/repo across C# history). The adapter runs Logbook's own detection + gating
|
|
87
86
|
// and returns classified suppressions, so the glue never imports its pattern. null = the
|
package/reflect.mjs
CHANGED
|
@@ -16,14 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import { join
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { join } from 'node:path';
|
|
21
20
|
import { record as memRecord } from './memory.mjs';
|
|
22
21
|
import { PW } from './bins.mjs';
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
// The MEMORY engine is reached only through memory.mjs (the Memory Contract seam), never
|
|
26
|
-
// resolved or shelled directly here.
|
|
23
|
+
// Logbook is reached only through memory.mjs, never resolved or shelled directly here.
|
|
27
24
|
|
|
28
25
|
const flag = (name, def) => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : def; };
|
|
29
26
|
const repo = flag('--repo', process.cwd());
|
|
@@ -35,9 +32,10 @@ const noAuto = process.argv.includes('--no-auto'); // user-level wiring: measu
|
|
|
35
32
|
const git = (...a) => execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim();
|
|
36
33
|
// backstop so the check can never hang a commit/turn; generous default, overridable.
|
|
37
34
|
const CHECK_TIMEOUT = +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 600000;
|
|
38
|
-
//
|
|
39
|
-
// commit
|
|
40
|
-
|
|
35
|
+
// A Sawbuck-Allow or Promptwheel-Allow trailer never clears the verdict, whichever hook ran this: the git hook cannot
|
|
36
|
+
// tell who made a commit, and an agent can write a commit message. The override is a person running
|
|
37
|
+
// `sawbuck check --allow "<reason>"`.
|
|
38
|
+
const childEnv = { ...process.env, PROMPTWHEEL_NO_ALLOW_TRAILER: '1' };
|
|
41
39
|
const runNode = (file, a) => execFileSync(process.execPath, [file, ...a], { cwd: repo, encoding: 'utf8', env: childEnv, stdio: ['ignore', 'pipe', 'pipe'], timeout: CHECK_TIMEOUT, killSignal: 'SIGKILL' });
|
|
42
40
|
|
|
43
41
|
// --- 1) the check: measure the commit, parse the structured result ----------
|
|
@@ -83,6 +81,9 @@ if (standing) {
|
|
|
83
81
|
process.exit(0);
|
|
84
82
|
}
|
|
85
83
|
if (verdict !== 'gamed' && verdict !== 'fail') process.exit(0); // only breakage is worth recording
|
|
84
|
+
// A fail that is only a protected path, under the Claude hooks: the Stop hook's wall counts it and sends the turn
|
|
85
|
+
// back, so counting it here too would show two catches for one change. The git hook has no wall, so it records it.
|
|
86
|
+
if (verdict === 'fail' && by && (report.mandates || []).length && !(report.metrics || []).some((m) => m.guard && m.ok === false)) process.exit(0);
|
|
86
87
|
|
|
87
88
|
// map the engine verdict to Sawbuck's plain outcome words
|
|
88
89
|
const kind = verdict === 'gamed' ? 'gamed' : 'regression';
|
|
@@ -92,45 +93,53 @@ const sha = git('rev-parse', head);
|
|
|
92
93
|
const changed = git('diff', '--name-only', base, head).split('\n').filter(Boolean);
|
|
93
94
|
const testish = (f) => /(\.test\.|\.spec\.|__tests__|\/tests?\/|\.config\.|golden|fixture|snapshot)/i.test(f);
|
|
94
95
|
const srcFiles = changed.filter((f) => !testish(f));
|
|
95
|
-
|
|
96
|
+
// a gamed win is named by the goalpost that moved (a test first, then config or a golden file); a regression by the source
|
|
97
|
+
const isTest = (f) => /(\.test\.|\.spec\.|__tests__|(^|\/)tests?\/)/i.test(f);
|
|
98
|
+
// a protected path that was touched is the finding, whatever else the change did: name the rule and the file
|
|
99
|
+
const hit = kind === 'regression' ? protectedHit(report) : null;
|
|
100
|
+
const evidenceFile = kind === 'gamed' ? (changed.find(isTest) || changed.find(testish) || changed[0]) : hit ? hit.file : (srcFiles[0] || changed[0]);
|
|
96
101
|
if (!evidenceFile) process.exit(0);
|
|
97
102
|
|
|
98
103
|
const gamedMetric = (report.metrics || []).find((m) => m.gamed === true);
|
|
99
104
|
const reason = kind === 'gamed'
|
|
100
105
|
? `sawbuck: a change here was gamed, ${gamedMetric?.gamingReason || 'a metric "improved" by weakening its test, not the source'}`
|
|
106
|
+
: hit ? `sawbuck: a change here ${hit.why}`
|
|
101
107
|
: `sawbuck: a change here regressed a guarded metric beyond noise`;
|
|
102
108
|
|
|
103
109
|
// --- 3) record the finding in memory (unreviewed, machine-authored note) ----
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
// over the same span is the same catch, not a new one: no second note, no second tick.
|
|
108
|
-
const repeatOfStanding = standing && kind === 'gamed';
|
|
109
|
-
if (!repeatOfStanding) memRecord(repo, { sha, reason: reason.slice(0, 280), side: 'diff', evidenceFile, by: 'sawbuck' });
|
|
110
|
+
// A note on the commit, through the Logbook adapter. A gamed verdict still standing over the same span never
|
|
111
|
+
// gets here (it exited above), so one catch is one note and one tick.
|
|
112
|
+
memRecord(repo, { sha, reason: reason.slice(0, 280), by: 'sawbuck' });
|
|
110
113
|
|
|
111
114
|
// --- 4) tick the results record (the visible proof-of-work) -----------------
|
|
112
|
-
if (
|
|
115
|
+
if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1;
|
|
113
116
|
rec.latest = { sha: sha.slice(0, 8), kind, base: baseSha || null, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString(), by: by || null };
|
|
114
117
|
writeRec();
|
|
115
118
|
|
|
116
119
|
// --- 5) quiet by default; one line to stderr -
|
|
117
120
|
process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
|
|
118
121
|
|
|
122
|
+
// the first protected path (mandate) the engine reports as touched, in words
|
|
123
|
+
function protectedHit(rep) {
|
|
124
|
+
const m = (rep.mandates || []).find((x) => x && (x.files || []).length);
|
|
125
|
+
return m ? { file: m.files[0], why: `touched the protected path "${m.name}": ${m.files.slice(0, 3).join(', ')}${m.files.length > 3 ? `, and ${m.files.length - 3} more` : ''}` } : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
// --- the activity feed: one line per check so you can watch it work, verified
|
|
120
129
|
// included, not only catches. Capped, lives in the git-excluded .sawbuck/, opt
|
|
121
|
-
// out with SAWBUCK_NO_LOG=1. `sawbuck log` reads it.
|
|
130
|
+
// out with SAWBUCK_NO_LOG=1. `sawbuck status --log` reads it.
|
|
122
131
|
function appendActivity(v, rep, standing = false) {
|
|
123
132
|
if (process.env.SAWBUCK_NO_LOG) return;
|
|
124
133
|
const map = { pass: 'verified', gamed: 'gamed', fail: 'regression', inconclusive: 'inconclusive' };
|
|
125
|
-
const k =
|
|
134
|
+
const k = map[v]; // no hook passes --allow and a trailer clears nothing, so there is no "allowed" to log here
|
|
126
135
|
if (!k) return;
|
|
127
136
|
let sha = '', file = '';
|
|
128
137
|
try { sha = git('rev-parse', head).slice(0, 8); } catch {}
|
|
129
|
-
|
|
138
|
+
const hit = k === 'regression' ? protectedHit(rep) : null;
|
|
139
|
+
try { file = (hit && hit.file) || git('diff', '--name-only', base, head).split('\n').filter(Boolean)[0] || ''; } catch {}
|
|
130
140
|
const gm = (rep.metrics || []).find((m) => m.gamed === true);
|
|
131
141
|
const why = k === 'gamed' ? (gm?.gamingReason || 'the win came from editing the goalposts')
|
|
132
|
-
: k === 'regression' ? 'a guarded metric regressed beyond noise'
|
|
133
|
-
: k === 'allowed' ? ('declared intentional: ' + (rep.allowed?.reason || ''))
|
|
142
|
+
: k === 'regression' ? (hit ? `the change ${hit.why}` : 'a guarded metric regressed beyond noise')
|
|
134
143
|
: k === 'verified' ? 'the check passed'
|
|
135
144
|
: 'could not certify a pass';
|
|
136
145
|
// `standing`: this check re-proved the span of a gamed verdict that is still up, not a new catch
|
package/repo-state.mjs
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
// One read of what Sawbuck has here, shared by the statusline and `sawbuck status`. Files only, no git and no model.
|
|
2
|
-
// state: watching (wired
|
|
2
|
+
// state: watching (hooks wired for this repo), ready (checkable, not wired), memory (history only), or empty.
|
|
3
3
|
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
5
6
|
|
|
6
7
|
const WEEK = 7 * 864e5;
|
|
8
|
+
export const OURS = /sawbuck\.mjs|npx -y sawbuck@/; // a hook of ours: the baked path, or the portable form --shared writes
|
|
9
|
+
export const S_START = '# >>> sawbuck >>>';
|
|
10
|
+
const text = (f) => { try { return readFileSync(f, 'utf8'); } catch { return ''; } };
|
|
11
|
+
// our hooks in a Claude Code settings file: under "hooks", so a permission that names sawbuck is not wiring
|
|
12
|
+
export const hooksIn = (f) => { try { return OURS.test(JSON.stringify(JSON.parse(readFileSync(f, 'utf8')).hooks || {})); } catch { return false; } };
|
|
13
|
+
|
|
14
|
+
// Which hooks are wired for this repo: ours in the project settings, our block in the post-commit hook, or ours in
|
|
15
|
+
// the user-level settings. '' when none are, whatever record an earlier attach left on disk.
|
|
16
|
+
export function wiredBy(repo, home = homedir()) {
|
|
17
|
+
if (['settings.local.json', 'settings.json'].some((f) => hooksIn(join(repo, '.claude', f)))) return 'project';
|
|
18
|
+
// .git is a file in a submodule or a linked worktree. A linked worktree has no hooks of its own, and its commits are not checked.
|
|
19
|
+
const m = /^gitdir: (.+)$/m.exec(text(join(repo, '.git')));
|
|
20
|
+
if (text(join(m ? resolve(repo, m[1].trim()) : join(repo, '.git'), 'hooks', 'post-commit')).includes(S_START)) return 'git';
|
|
21
|
+
return hooksIn(join(home, '.claude', 'settings.json')) ? 'user' : '';
|
|
22
|
+
}
|
|
7
23
|
|
|
8
24
|
export function repoState(repo) {
|
|
9
25
|
const record = (f) => { try { return JSON.parse(readFileSync(join(repo, '.sawbuck', f), 'utf8')); } catch { return null; } };
|
|
@@ -12,11 +28,12 @@ export function repoState(repo) {
|
|
|
12
28
|
const caught = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
|
|
13
29
|
const hasConfig = has('promptwheel.config.json');
|
|
14
30
|
const armed = hasConfig || has('package.json');
|
|
15
|
-
const
|
|
31
|
+
const wired = wiredBy(repo);
|
|
32
|
+
const state = wired ? 'watching' : armed ? 'ready' : (has('LOGBOOK.md') || has('events.jsonl')) ? 'memory' : '';
|
|
16
33
|
// the reins engine's file; reins does the model work, never sawbuck. A rate nobody has updated for a week is
|
|
17
34
|
// history, not a reading: the judge was turned off, or the plugin is not running.
|
|
18
35
|
const s = record('steer.json');
|
|
19
36
|
const live = s && s.scored && !(s.at && Date.now() - Date.parse(s.at) > WEEK);
|
|
20
37
|
const steer = live ? { rate: Math.round(((s.ema != null ? s.ema : s.steeringRate) || 0) * 100), scored: s.scored } : null;
|
|
21
|
-
return { results, caught, hasConfig, armed, state, steer };
|
|
38
|
+
return { results, caught, hasConfig, armed, wired, state, steer };
|
|
22
39
|
}
|
package/rules.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Find the rules you keep enforcing and never wrote down. `prepare` gathers what you typed to Claude Code, on this
|
|
2
2
|
// machine, with copies and likely secrets removed. The agent proposes rules from it, each with quotes. `check` proves
|
|
3
3
|
// every quote is really yours: a rule with fewer than two real quotes is dropped. Nothing is added to any file here.
|
|
4
|
-
|
|
5
|
-
import {
|
|
4
|
+
// Once the check has run, the gathered messages and the candidates are deleted: they were for that one read.
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join, resolve, dirname } from 'node:path';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
8
|
import { readHistory, typedText } from './history.mjs';
|
|
8
9
|
|
|
@@ -10,6 +11,11 @@ const MAX_LINES = 3000;
|
|
|
10
11
|
export 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
12
|
const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
12
13
|
const paths = (home) => ({ dir: join(home, '.sawbuck'), input: join(home, '.sawbuck', 'rule-input.txt') });
|
|
14
|
+
// remove what one read gathered. A file the model wrote counts only when it sits in ~/.sawbuck: anywhere else it is the person's own.
|
|
15
|
+
export function forget(home, ...files) {
|
|
16
|
+
const dir = resolve(paths(home).dir);
|
|
17
|
+
for (const f of files) if (f && dirname(resolve(f)) === dir) rmSync(f, { force: true });
|
|
18
|
+
}
|
|
13
19
|
|
|
14
20
|
export function prepareRules({ home = homedir() } = {}) {
|
|
15
21
|
const found = [];
|
|
@@ -32,6 +38,7 @@ export function prepareRules({ home = homedir() } = {}) {
|
|
|
32
38
|
const { dir, input } = paths(home);
|
|
33
39
|
mkdirSync(dir, { recursive: true });
|
|
34
40
|
writeFileSync(input, lines.join('\n') + '\n', { mode: 0o600 });
|
|
41
|
+
chmodSync(input, 0o600); // the mode above only applies to a new file
|
|
35
42
|
return { input, lines: lines.length, secrets };
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -59,6 +66,7 @@ export function rulesCli(args, { home = homedir() } = {}) {
|
|
|
59
66
|
if (!file || !existsSync(file) || !statSync(file).isFile()) return '🐴 sawbuck: --check needs the candidates file.';
|
|
60
67
|
if (!existsSync(paths(home).input)) return '🐴 sawbuck: run sawbuck rules --prepare first.';
|
|
61
68
|
const { kept, dropped } = checkRules(file, { home });
|
|
69
|
+
forget(home, paths(home).input, file);
|
|
62
70
|
const out = [`🐴 ${kept.length} rules have at least 2 quotes that are really yours${dropped.length ? `, ${dropped.length} dropped` : ''}.`];
|
|
63
71
|
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
72
|
return out.join('\n');
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -6,11 +6,11 @@ import { ensureExcluded } from './exclude.mjs';
|
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from 'node:fs';
|
|
9
|
-
import { join, dirname
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
|
|
12
12
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
import { mandateHits, hashOf,
|
|
13
|
+
import { mandateHits, hashOf, protectedAmong, lockState } from './contract.mjs';
|
|
14
14
|
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
|
|
15
15
|
const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
|
|
16
16
|
const stop = process.argv.includes('--stop'); // wired as the Claude Code Stop hook: enforce the contract on a gamed turn
|
|
@@ -182,8 +182,3 @@ if (total > (st.shownTotal || 0)) {
|
|
|
182
182
|
st.shownTotal = total;
|
|
183
183
|
}
|
|
184
184
|
saveState();
|
|
185
|
-
|
|
186
|
-
// Keep the record dirs out of `git status` via .git/info/exclude, so Sawbuck never
|
|
187
|
-
// dirties the worktree, including under `attach --user`, which is global and cannot
|
|
188
|
-
// set this up per-repo at attach time. Idempotent; markers match the CLI's so a
|
|
189
|
-
// per-repo `detach` removes them.
|