sawbuck 0.1.29 → 0.1.30
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 -0
- package/package.json +1 -1
- package/sawbuck-hook.mjs +19 -30
- package/sawbuck.mjs +17 -7
package/contract.mjs
CHANGED
|
@@ -53,6 +53,12 @@ export function mandatesInForce(repo, ref = 'HEAD') {
|
|
|
53
53
|
return [...wt, ...committed];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// the paths among `paths` that a mandate in force covers (any rule, working tree or committed)
|
|
57
|
+
export function protectedAmong(repo, paths) {
|
|
58
|
+
const res = mandatesInForce(repo).flatMap((m) => pathsOf(m).map(globToRe));
|
|
59
|
+
return paths.filter((f) => res.some((re) => re.test(f)));
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
// a working-tree file's content hash as git would store it; 'deleted' when it is gone
|
|
57
63
|
export function hashOf(repo, file) {
|
|
58
64
|
try { return gitOut(repo, ['hash-object', '--', file]).trim(); } catch { return 'deleted'; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
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
|
@@ -10,7 +10,7 @@ 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 } from './contract.mjs';
|
|
13
|
+
import { mandateHits, hashOf, protectedDirty, protectedAmong } 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 repo = flag('--repo', process.cwd());
|
|
16
16
|
const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
|
|
@@ -46,23 +46,6 @@ 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
|
-
}
|
|
66
49
|
|
|
67
50
|
// 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
|
|
68
51
|
// tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
|
|
@@ -70,34 +53,40 @@ function lastPromptTime(transcriptPath) {
|
|
|
70
53
|
// and send the agent back to undo its protected edits and make the fix elsewhere. The rules are the
|
|
71
54
|
// working tree's AND the committed contract's (editing the config in the same turn lifts nothing),
|
|
72
55
|
// and a protected file that was already dirty with the same content when the prompt began (the
|
|
73
|
-
// baseline `sawbuck turn` recorded) is the person's own work and does not count.
|
|
56
|
+
// baseline `sawbuck turn --hook` recorded) is the person's own work and does not count. A protected
|
|
57
|
+
// edit COMMITTED since the baseline's commit counts too: a commit mid-turn does not walk through.
|
|
74
58
|
if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
59
|
+
const now = new Date().toISOString();
|
|
75
60
|
const dirty = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 = {};
|
|
61
|
+
const base = (st.turnBaseline && typeof st.turnBaseline === 'object') ? st.turnBaseline : null;
|
|
62
|
+
const baseline = (base && base.files && typeof base.files === 'object') ? base.files : {};
|
|
63
|
+
const committedSince = (base && base.sha && head && base.sha !== head) ? git('diff', '--name-only', base.sha, 'HEAD').split('\n').filter(Boolean) : [];
|
|
82
64
|
const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
|
|
83
65
|
const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
|
|
84
|
-
const changed = dirty.filter((f) => !untouched(f));
|
|
66
|
+
const changed = [...new Set([...dirty.filter((f) => !untouched(f)), ...committedSince])];
|
|
85
67
|
const leftAlone = dirty.filter(untouched);
|
|
86
68
|
const hits = changed.length ? mandateHits(repo, changed) : [];
|
|
87
|
-
|
|
88
|
-
if (hits.length) {
|
|
89
|
-
|
|
69
|
+
st.lastStopAt = now; // the next prompt may take a fresh baseline only because this stop happened
|
|
70
|
+
if (hits.length) {
|
|
71
|
+
// remember EVERY changed protected path (dirty or committed since the baseline) and what it holds,
|
|
72
|
+
// uncapped, and the commit the baseline stood at, so the next prompt cannot adopt any of it
|
|
73
|
+
const files = protectedDirty(repo);
|
|
74
|
+
for (const f of protectedAmong(repo, committedSince)) if (!Object.prototype.hasOwnProperty.call(files, f)) files[f] = hashOf(repo, f);
|
|
75
|
+
st.lastViolation = { at: now, sha: (base && base.sha) || null, files };
|
|
76
|
+
} else if (st.lastViolation) { delete st.lastViolation; }
|
|
77
|
+
saveState();
|
|
90
78
|
if (hits.length) {
|
|
91
79
|
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
92
80
|
const promptId = hookInput.prompt_id || 'none';
|
|
93
81
|
if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
|
|
94
82
|
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('; ');
|
|
95
83
|
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(', ')}.` : '';
|
|
84
|
+
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.)';
|
|
96
85
|
try { mkdirSync(stDir, { recursive: true }); appendFileSync(join(stDir, 'activity.jsonl'), JSON.stringify({ at: new Date().toISOString(), kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' }) + '\n'); } catch {}
|
|
97
86
|
if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
|
|
98
87
|
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
99
88
|
saveState();
|
|
100
|
-
process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a mandate in the contract was violated, 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}` }));
|
|
89
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a mandate in the contract was violated, 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}${upgrade}` }));
|
|
101
90
|
process.exit(0);
|
|
102
91
|
}
|
|
103
92
|
saveState();
|
package/sawbuck.mjs
CHANGED
|
@@ -210,9 +210,11 @@ switch (cmd) {
|
|
|
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
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.
|
|
214
|
-
//
|
|
215
|
-
//
|
|
213
|
+
// `sawbuck turn` never waits on a pipe. Three rules keep the baseline honest: a new baseline is
|
|
214
|
+
// taken only when the previous turn actually reached a Stop (an interrupt, a crash, or a re-run of
|
|
215
|
+
// this command mid-turn leaves the last one standing, so nothing the agent left behind is adopted);
|
|
216
|
+
// a file still carrying the content the last violation flagged is never baselined; and the
|
|
217
|
+
// baseline's commit does not advance past a flagged commit until a clean stop.
|
|
216
218
|
let hookEvent = '';
|
|
217
219
|
if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
218
220
|
if (hookEvent === 'UserPromptSubmit') {
|
|
@@ -220,10 +222,18 @@ switch (cmd) {
|
|
|
220
222
|
if (mandatesInForce(repo).length) {
|
|
221
223
|
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
222
224
|
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
const now = new Date().toISOString();
|
|
226
|
+
const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
|
|
227
|
+
const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
|
|
228
|
+
const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
|
|
229
|
+
if (!unfinished) {
|
|
230
|
+
const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
|
|
231
|
+
const files = protectedDirty(repo);
|
|
232
|
+
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
|
|
233
|
+
let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
|
|
234
|
+
st.turnBaseline = { at: now, sha: (st.lastViolation && st.lastViolation.sha) || sha, files };
|
|
235
|
+
}
|
|
236
|
+
st.lastPromptAt = now;
|
|
227
237
|
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
228
238
|
}
|
|
229
239
|
} catch { /* the frame never fails a prompt */ }
|