sawbuck 0.1.43 → 0.1.45

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
@@ -28,8 +28,8 @@ let paths = multi('--file');
28
28
  if (!paths.length) paths = git('diff', '--name-only', base, head).split('\n').filter(Boolean);
29
29
  if (!paths.length) { console.log(asJson ? '[]' : 'rank: no paths to rank'); process.exit(0); }
30
30
 
31
- // the full event record (the memory provider's recall facts), ranked client-side below
32
- // (the provider's --file does not narrow the record, so we pull it once and filter by path here).
31
+ // the full event record (Logbook's facts), ranked client-side below
32
+ // (Logbook's --file does not narrow the record, so we pull it once and filter by path here).
33
33
  const EVENTS = memEvents(repo);
34
34
 
35
35
  const resolveRename = (f) => f.replace(/\{[^}]*=>\s*([^}]*)\}/g, '$1').replace(/\/{2,}/g, '/');
@@ -83,9 +83,9 @@ const DEBT_MIN = Math.max(1, +(process.env.SAWBUCK_DEBT_MIN) || 6);
83
83
  // Still-live filter + ambient debt, via the memory adapter (the Memory Contract's `debt`
84
84
  // capability), scoped to the touched paths so the per-turn hook stays cheap. A suppression on a path
85
85
  // now CLEAN of suppressions is churn, not current risk (the study: AutoMapper carries 4 live
86
- // `[Ignore]` vs ~158/repo across C# history). The adapter runs the provider's own detection + gating
87
- // and returns classified suppressions, so the glue never imports the provider's pattern. null = the
88
- // provider failed -> treat all as live (do not penalize); [] = genuinely clean. --raw turns it off.
86
+ // `[Ignore]` vs ~158/repo across C# history). The adapter runs Logbook's own detection + gating
87
+ // and returns classified suppressions, so the glue never imports its pattern. null = the
88
+ // Logbook failed -> treat all as live (do not penalize); [] = genuinely clean. --raw turns it off.
89
89
  let liveSet = null;
90
90
  const liveDebt = new Map(); // path -> { n, skips, bare }: live suppressions, for ambient surfacing
91
91
  if (!raw && paths.length) {
package/reflect.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // Run the check (the promptwheel engine) on the latest commit; on a gamed or
5
5
  // regression result, deterministic ground truth, record a finding in the
6
6
  // memory (the logbook engine) on the touched source paths, and tick the results
7
- // record. Quiet by default; the dispatch surfaces catches on a cadence.
7
+ // record. Quiet by default; the Stop hook says a fresh catch once.
8
8
  //
9
9
  // Zero-dep, Node ESM. Orchestrates the engine CLIs behind a clean layer
10
10
  // boundary, never reaching into their source.
@@ -113,7 +113,7 @@ if (!repeatOfStanding) { if (kind === 'gamed') rec.gamed += 1; else rec.regressi
113
113
  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
114
  writeRec();
115
115
 
116
- // --- 5) quiet by default (dispatch surfaces on cadence); one line to stderr -
116
+ // --- 5) quiet by default; one line to stderr -
117
117
  process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
118
118
 
119
119
  // --- the activity feed: one line per check so you can watch it work, verified
package/repo-state.mjs ADDED
@@ -0,0 +1,22 @@
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.
3
+ import { readFileSync, existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ const WEEK = 7 * 864e5;
7
+
8
+ export function repoState(repo) {
9
+ const record = (f) => { try { return JSON.parse(readFileSync(join(repo, '.sawbuck', f), 'utf8')); } catch { return null; } };
10
+ const has = (f) => existsSync(join(repo, f));
11
+ const results = record('results.json') || {};
12
+ const caught = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
13
+ const hasConfig = has('promptwheel.config.json');
14
+ const armed = hasConfig || has('package.json');
15
+ const state = has('.sawbuck/state.json') ? 'watching' : armed ? 'ready' : (has('LOGBOOK.md') || has('events.jsonl')) ? 'memory' : '';
16
+ // the reins engine's file; reins does the model work, never sawbuck. A rate nobody has updated for a week is
17
+ // history, not a reading, and the judge is off unless someone turned it on.
18
+ const s = record('steer.json');
19
+ const live = s && s.scored && !(s.at && Date.now() - Date.parse(s.at) > WEEK);
20
+ 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 };
22
+ }
package/rules.mjs CHANGED
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
7
7
  import { readHistory, typedText } from './history.mjs';
8
8
 
9
9
  const MAX_LINES = 3000;
10
- 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
+ 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
11
  const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
12
12
  const paths = (home) => ({ dir: join(home, '.sawbuck'), input: join(home, '.sawbuck', 'rule-input.txt') });
13
13
 
package/sawbuck-hook.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureExcluded } from './exclude.mjs';
3
3
  // sawbuck-hook.mjs: the Sawbuck Claude Code Stop-hook entry.
4
- // Each turn: if a new commit landed, run reflect on it; then render the dispatch
5
- // on a fresh catch OR every N turns (config). State in .sawbuck/state.json.
4
+ // Each turn: if a new commit landed, run reflect on it; then say so once on a fresh catch. State in .sawbuck/state.json.
6
5
  // Zero-dep. Usage (wired as a Stop hook): node sawbuck-hook.mjs --repo <path>
7
6
 
8
7
  import { execFileSync } from 'node:child_process';
@@ -32,7 +31,6 @@ const node = (f, a) => { try { return execFileSync(process.execPath, [f, ...a],
32
31
 
33
32
  let cfg = {};
34
33
  try { cfg = JSON.parse(readFileSync(join(repo, 'sawbuck.config.json'), 'utf8')); } catch {}
35
- const EVERY_TURNS = 5;
36
34
 
37
35
  const stDir = join(repo, '.sawbuck');
38
36
  const stPath = join(stDir, 'state.json');
@@ -72,6 +70,20 @@ st.turns += 1;
72
70
  // 2) the record of what the check has charted (read above, after any re-run)
73
71
  const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) + (rec.mandates || 0) : 0;
74
72
  const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
73
+ // the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp (written by
74
+ // `sawbuck turn --hook`), never a constant that would spend it for good
75
+ const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
76
+ // Send the turn back, at most enforce.maxBlocks times per prompt, then let it end with a warning: it can never
77
+ // loop, because the harness's own block cap resets on every tool call.
78
+ const sendBack = (idKey, countKey, reason, stillOpen) => {
79
+ const max = +(cfg.enforce?.maxBlocks) || 3;
80
+ if (st[idKey] !== promptId) { st[idKey] = promptId; st[countKey] = 0; }
81
+ const spent = (st[countKey] || 0) >= max;
82
+ if (!spent) st[countKey] = (st[countKey] || 0) + 1;
83
+ saveState();
84
+ process.stdout.write(JSON.stringify(spent ? { systemMessage: `Sawbuck stopped blocking after ${max} tries: ${stillOpen}` } : { decision: 'block', reason }));
85
+ process.exit(0);
86
+ };
75
87
 
76
88
  // 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
77
89
  // tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
@@ -112,18 +124,13 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
112
124
  } else if (st.lastViolation) { delete st.lastViolation; }
113
125
  saveState();
114
126
  if (hits.length) {
115
- const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
116
- // the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp
117
- // (written by `sawbuck turn --hook`), never a constant that would spend the budget for good
118
- const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
119
- if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
120
127
  const desc = hits.map((h) => `"${h.name}"${h.reason ? ' (' + h.reason + ')' : ''}: ${h.files.join(', ')}${h.committed ? ' [this rule is in the committed contract; editing promptwheel.config.json in the working tree does not lift it]' : ''}`).join('; ');
121
128
  const spared = leftAlone.length ? ` Edits that were already there before this prompt were left out of this check and must stay as they are: ${leftAlone.join(', ')}.` : '';
122
129
  const before = (st.lastViolation && st.lastViolation.before) || {};
123
130
  const restore = Object.keys(before).length ? ' To put back what you overwrote: ' + Object.entries(before).map(([f, h]) => `restore ${f} to the content it had when the prompt began: git cat-file -p ${h} > ${f}`).join('; ') + '.' : '';
124
131
  const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
125
132
  const accept = committedHit ? ' To accept this change on purpose, change the rule in the contract or revert the path.' : '';
126
- const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach --claude again, which upgrades the hooks.)';
133
+ const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach again, which upgrades the hooks.)';
127
134
  // one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
128
135
  // a block must not replace a gamed verdict that is still standing.
129
136
  if (st.mandateCountedPrompt !== promptId) {
@@ -134,15 +141,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
134
141
  writeRec(r);
135
142
  logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
136
143
  }
137
- if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
138
- st.mandateBlocks = (st.mandateBlocks || 0) + 1;
139
- saveState();
140
- process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a protected path in the contract was touched, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${restore}${accept}${upgrade}` }));
141
- process.exit(0);
142
- }
143
- saveState();
144
- process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: protected paths are still changed (${desc}).` }));
145
- process.exit(0);
144
+ sendBack('mandatePromptId', 'mandateBlocks', `Sawbuck: a protected path in the contract was touched, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${restore}${accept}${upgrade}`, `protected paths are still changed (${desc}).`);
146
145
  }
147
146
  }
148
147
 
@@ -152,21 +151,9 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
152
151
  // bound a tool-using agent on its own. Opt out with SAWBUCK_NO_ENFORCE=1 (falls back to a flag).
153
152
  const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
154
153
  if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
155
- const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
156
- const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
157
- if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
158
154
  st.shownTotal = total;
159
155
  const where = rec.latest.file;
160
- if ((st.blockCount || 0) < MAX_BLOCKS) {
161
- st.blockCount = (st.blockCount || 0) + 1;
162
- saveState();
163
- const reason = `Sawbuck: the win on ${where} was gamed. It came from editing the test or config, not the source, so it did not survive a source-only re-run. The turn is not done: fix the source so the metric passes on its own, then finish.`;
164
- process.stdout.write(JSON.stringify({ decision: 'block', reason }));
165
- process.exit(0);
166
- }
167
- saveState(); // budget spent: let the turn end, but warn loudly so it never loops forever
168
- process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: the win on ${where} is still gamed and needs a real source fix.` }));
169
- process.exit(0);
156
+ sendBack('blockPromptId', 'blockCount', `Sawbuck: the win on ${where} was gamed. It came from editing the test or config, not the source, so it did not survive a source-only re-run. The turn is not done: fix the source so the metric passes on its own, then finish.`, `the win on ${where} is still gamed and needs a real source fix.`);
170
157
  }
171
158
 
172
159
  // 3b) THE LOCK AT TURN LEVEL: tell the person the moment a turn changes the repo's structure or its resolved
@@ -188,12 +175,10 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE && st.turnBaseline && st.turnBaselin
188
175
  }
189
176
  }
190
177
 
191
- // 4) otherwise, surface a catch on a fresh catch or on cadence (a flag; never blocks)
192
- const freshCatch = total > (st.shownTotal || 0);
193
- const cadenceHit = st.turns % EVERY_TURNS === 0;
194
- if (freshCatch || cadenceHit) {
195
- const out = node(join(HERE, 'dispatch.mjs'), ['--repo', repo]);
196
- if (out.trim()) process.stdout.write(out.endsWith('\n') ? out : out + '\n');
178
+ // 4) otherwise, say a fresh catch once (a flag; never blocks). The band and statusline carry the rest.
179
+ if (total > (st.shownTotal || 0)) {
180
+ const L = rec && rec.latest;
181
+ process.stdout.write(`🐴 Sawbuck caught a ${L && L.kind === 'gamed' ? 'gamed change' : 'problem'}${L && L.file ? ` in ${L.file}` : ''}${L && L.reason ? `: ${L.reason}` : ''}\n`);
197
182
  st.shownTotal = total;
198
183
  }
199
184
  saveState();