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/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 rises by itself in the per-turn context, no command run.
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 memory adapter (the Memory Contract's `debt`
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, dirname } from 'node:path';
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
- const HERE = dirname(fileURLToPath(import.meta.url));
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
- // a commit an agent made: its own Promptwheel-Allow trailer does not clear the verdict. An agent can write a
39
- // commit message, so the override for its work is a person running `sawbuck check --allow "<reason>"`.
40
- const childEnv = by ? { ...process.env, PROMPTWHEEL_NO_ALLOW_TRAILER: '1' } : process.env;
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
- const evidenceFile = srcFiles[0] || changed[0];
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
- // The Memory Contract's `record` capability, through the adapter (no direct engine shell here).
105
- // Kept simple: a commit-attached digest note. A later pass could add --span grounding
106
- // and true coupling (the fix diff -> "change X also needs Y"). A gamed verdict still standing
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 (!repeatOfStanding) { if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1; }
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 = (v === 'pass' && rep.allowed) ? 'allowed' : map[v]; // a declared override is logged distinctly, for the audit trail
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
- try { file = git('diff', '--name-only', base, head).split('\n').filter(Boolean)[0] || ''; } catch {}
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 in), ready (checkable, not wired yet), memory (history only), or empty.
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 state = has('.sawbuck/state.json') ? 'watching' : armed ? 'ready' : (has('LOGBOOK.md') || has('events.jsonl')) ? 'memory' : '';
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
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
5
- import { join } from 'node:path';
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, resolve } from 'node:path';
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, protectedDirty, protectedAmong, lockState } from './contract.mjs';
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.