sawbuck 0.1.28 → 0.1.29
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.mjs +6 -1
- package/package.json +1 -1
- package/sawbuck-hook.mjs +28 -2
- package/sawbuck.mjs +10 -3
package/contract.mjs
CHANGED
|
@@ -92,7 +92,12 @@ export function mandateHits(repo, changedPaths, opts = {}) {
|
|
|
92
92
|
const files = changedPaths.filter((f) => res.some((re) => re.test(f)));
|
|
93
93
|
if (!files.length) continue;
|
|
94
94
|
const prev = out.find((h) => h.name === String(m.name));
|
|
95
|
-
if (prev) {
|
|
95
|
+
if (prev) {
|
|
96
|
+
prev.files = [...new Set([...prev.files, ...files])].slice(0, 20);
|
|
97
|
+
if (!prev.reason && m.reason) prev.reason = String(m.reason).slice(0, 300);
|
|
98
|
+
if (m.committed) prev.committed = true; // the committed version of the rule hit too: say so
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
96
101
|
out.push({ name: String(m.name), reason: m.reason ? String(m.reason).slice(0, 300) : '', files: files.slice(0, 20), ...(m.committed ? { committed: true } : {}) });
|
|
97
102
|
}
|
|
98
103
|
return out;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves every win from the agent's source edits alone, so a green that came from editing the test, grader, or config is caught and sent back. Works in Claude Code, Codex, git hooks, and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -46,6 +46,23 @@ let rec = null;
|
|
|
46
46
|
try { rec = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch {}
|
|
47
47
|
const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
|
|
48
48
|
const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
|
|
49
|
+
// when the last prompt of this session was typed, from the transcript the hook names (null when unknown):
|
|
50
|
+
// the newest user row that carries text, not a tool result; the tail of the file is enough
|
|
51
|
+
function lastPromptTime(transcriptPath) {
|
|
52
|
+
try {
|
|
53
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
54
|
+
const text = readFileSync(transcriptPath, 'utf8'); const tail = text.length > 524288 ? text.slice(-524288) : text;
|
|
55
|
+
const lines = tail.split('\n');
|
|
56
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
57
|
+
let o; try { o = JSON.parse(lines[i]); } catch { continue; }
|
|
58
|
+
if (!o || o.type !== 'user' || !o.timestamp) continue;
|
|
59
|
+
const c = o.message && o.message.content;
|
|
60
|
+
const isText = typeof c === 'string' || (Array.isArray(c) && c.some((p) => p && p.type === 'text') && !c.some((p) => p && p.type === 'tool_result'));
|
|
61
|
+
if (isText) { const t = Date.parse(o.timestamp); return Number.isFinite(t) ? t : null; }
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
49
66
|
|
|
50
67
|
// 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
|
|
51
68
|
// tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
|
|
@@ -56,11 +73,20 @@ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFile
|
|
|
56
73
|
// baseline `sawbuck turn` recorded) is the person's own work and does not count.
|
|
57
74
|
if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
58
75
|
const dirty = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
|
|
59
|
-
|
|
60
|
-
|
|
76
|
+
// the baseline `sawbuck turn --hook` recorded when the prompt began; one recorded well AFTER the
|
|
77
|
+
// transcript's last prompt was not the prompt's (something re-ran `turn` mid-turn) and is ignored
|
|
78
|
+
let baseline = (st.turnBaseline && st.turnBaseline.files && typeof st.turnBaseline.files === 'object') ? st.turnBaseline.files : {};
|
|
79
|
+
const promptAt = lastPromptTime(hookInput.transcript_path);
|
|
80
|
+
const baseAt = Date.parse(st.turnBaseline && st.turnBaseline.at);
|
|
81
|
+
if (promptAt && baseAt && baseAt > promptAt + 30000) baseline = {};
|
|
82
|
+
const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
|
|
83
|
+
const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
|
|
61
84
|
const changed = dirty.filter((f) => !untouched(f));
|
|
62
85
|
const leftAlone = dirty.filter(untouched);
|
|
63
86
|
const hits = changed.length ? mandateHits(repo, changed) : [];
|
|
87
|
+
// remember what was flagged and what it held, so the next prompt's baseline cannot adopt it
|
|
88
|
+
if (hits.length) { const files = {}; for (const h of hits) for (const f of h.files) files[f] = hashOf(repo, f); st.lastViolation = { at: new Date().toISOString(), files }; }
|
|
89
|
+
else if (st.lastViolation) { delete st.lastViolation; saveState(); }
|
|
64
90
|
if (hits.length) {
|
|
65
91
|
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
66
92
|
const promptId = hookInput.prompt_id || 'none';
|
package/sawbuck.mjs
CHANGED
|
@@ -209,14 +209,21 @@ switch (cmd) {
|
|
|
209
209
|
// When a PROMPT begins (the UserPromptSubmit hook, never the SessionStart[compact] one, which
|
|
210
210
|
// fires mid-turn), remember which protected paths were already dirty and what they held, so the
|
|
211
211
|
// Stop hook's wall counts only what this turn changes and leaves a person's own edits alone.
|
|
212
|
+
// Only under --hook (the wiring passes it) is stdin read for the hook's JSON, so a plain
|
|
213
|
+
// `sawbuck turn` never waits on a pipe. Two rules keep the baseline honest: a file still carrying
|
|
214
|
+
// the content the last violation flagged is the agent's, not the person's, and is never
|
|
215
|
+
// baselined; and the Stop hook ignores a baseline recorded well after the prompt began.
|
|
212
216
|
let hookEvent = '';
|
|
213
|
-
if (!process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
217
|
+
if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
214
218
|
if (hookEvent === 'UserPromptSubmit') {
|
|
215
219
|
try {
|
|
216
220
|
if (mandatesInForce(repo).length) {
|
|
217
221
|
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
218
222
|
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
219
|
-
st.
|
|
223
|
+
const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
|
|
224
|
+
const files = protectedDirty(repo);
|
|
225
|
+
for (const f of Object.keys(files)) if (flagged[f] === files[f]) delete files[f]; // the agent's un-reverted edit stays on the wall
|
|
226
|
+
st.turnBaseline = { at: new Date().toISOString(), files };
|
|
220
227
|
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
221
228
|
}
|
|
222
229
|
} catch { /* the frame never fails a prompt */ }
|
|
@@ -459,7 +466,7 @@ function wireClaude(repo, opts) {
|
|
|
459
466
|
const fileExisted = existsSync(f);
|
|
460
467
|
let cfg = {};
|
|
461
468
|
if (fileExisted) { try { cfg = JSON.parse(readFileSync(f, 'utf8')); } catch { die(`${f} exists but is not valid JSON; I will not overwrite it. Add the hooks by hand.`); } }
|
|
462
|
-
const ctx = user ? `node ${JSON.stringify(SELF)} turn` : `node ${JSON.stringify(SELF)} turn ${JSON.stringify(repo)}`;
|
|
469
|
+
const ctx = user ? `node ${JSON.stringify(SELF)} turn --hook` : `node ${JSON.stringify(SELF)} turn --hook ${JSON.stringify(repo)}`;
|
|
463
470
|
const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
|
|
464
471
|
// attached already? "already attached" only when every hook of THIS version is present; an older
|
|
465
472
|
// attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
|