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/CONTRACT.md +2 -2
- package/README.md +20 -21
- package/agent-block.mjs +9 -16
- package/contract-cmd.mjs +32 -23
- package/contract.mjs +1 -1
- package/debt.mjs +3 -3
- package/exclude.mjs +16 -10
- package/memory.mjs +16 -156
- package/package.json +3 -8
- package/rank.mjs +5 -5
- package/reflect.mjs +2 -2
- package/repo-state.mjs +22 -0
- package/rules.mjs +1 -1
- package/sawbuck-hook.mjs +22 -37
- package/sawbuck.mjs +76 -223
- package/shape.mjs +35 -50
- package/status.mjs +6 -12
- package/steers.mjs +1 -1
- package/dispatch.mjs +0 -34
- package/exposure.mjs +0 -104
- package/providers/claude.mjs +0 -49
- package/providers/local.mjs +0 -55
- package/providers/mem0.mjs +0 -120
- package/providers/obsidian.mjs +0 -119
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 (
|
|
32
|
-
// (
|
|
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
|
|
87
|
-
// and returns classified suppressions, so the glue never imports
|
|
88
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
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();
|