sawbuck 0.1.28 → 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 +12 -1
- package/package.json +1 -1
- package/sawbuck-hook.mjs +21 -6
- package/sawbuck.mjs +20 -3
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'; }
|
|
@@ -92,7 +98,12 @@ export function mandateHits(repo, changedPaths, opts = {}) {
|
|
|
92
98
|
const files = changedPaths.filter((f) => res.some((re) => re.test(f)));
|
|
93
99
|
if (!files.length) continue;
|
|
94
100
|
const prev = out.find((h) => h.name === String(m.name));
|
|
95
|
-
if (prev) {
|
|
101
|
+
if (prev) {
|
|
102
|
+
prev.files = [...new Set([...prev.files, ...files])].slice(0, 20);
|
|
103
|
+
if (!prev.reason && m.reason) prev.reason = String(m.reason).slice(0, 300);
|
|
104
|
+
if (m.committed) prev.committed = true; // the committed version of the rule hit too: say so
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
96
107
|
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
108
|
}
|
|
98
109
|
return out;
|
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
|
|
@@ -53,25 +53,40 @@ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFile
|
|
|
53
53
|
// and send the agent back to undo its protected edits and make the fix elsewhere. The rules are the
|
|
54
54
|
// working tree's AND the committed contract's (editing the config in the same turn lifts nothing),
|
|
55
55
|
// and a protected file that was already dirty with the same content when the prompt began (the
|
|
56
|
-
// 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.
|
|
57
58
|
if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
59
|
+
const now = new Date().toISOString();
|
|
58
60
|
const dirty = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
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) : [];
|
|
64
|
+
const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
|
|
65
|
+
const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
|
|
66
|
+
const changed = [...new Set([...dirty.filter((f) => !untouched(f)), ...committedSince])];
|
|
62
67
|
const leftAlone = dirty.filter(untouched);
|
|
63
68
|
const hits = changed.length ? mandateHits(repo, changed) : [];
|
|
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();
|
|
64
78
|
if (hits.length) {
|
|
65
79
|
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
66
80
|
const promptId = hookInput.prompt_id || 'none';
|
|
67
81
|
if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
|
|
68
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('; ');
|
|
69
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.)';
|
|
70
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 {}
|
|
71
86
|
if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
|
|
72
87
|
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
73
88
|
saveState();
|
|
74
|
-
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}` }));
|
|
75
90
|
process.exit(0);
|
|
76
91
|
}
|
|
77
92
|
saveState();
|
package/sawbuck.mjs
CHANGED
|
@@ -209,14 +209,31 @@ 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. 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.
|
|
212
218
|
let hookEvent = '';
|
|
213
|
-
if (!process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
219
|
+
if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
214
220
|
if (hookEvent === 'UserPromptSubmit') {
|
|
215
221
|
try {
|
|
216
222
|
if (mandatesInForce(repo).length) {
|
|
217
223
|
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
218
224
|
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
219
|
-
|
|
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;
|
|
220
237
|
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
221
238
|
}
|
|
222
239
|
} catch { /* the frame never fails a prompt */ }
|
|
@@ -459,7 +476,7 @@ function wireClaude(repo, opts) {
|
|
|
459
476
|
const fileExisted = existsSync(f);
|
|
460
477
|
let cfg = {};
|
|
461
478
|
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)}`;
|
|
479
|
+
const ctx = user ? `node ${JSON.stringify(SELF)} turn --hook` : `node ${JSON.stringify(SELF)} turn --hook ${JSON.stringify(repo)}`;
|
|
463
480
|
const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
|
|
464
481
|
// attached already? "already attached" only when every hook of THIS version is present; an older
|
|
465
482
|
// attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
|