sawbuck 0.1.27 → 0.1.28
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 +64 -7
- package/package.json +1 -1
- package/sawbuck-hook.mjs +13 -5
- package/sawbuck.mjs +29 -5
package/contract.mjs
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// (frame / turn) and the Stop hook (the turn-level wall), so there is one resolver and one renderer.
|
|
3
3
|
// Read-only, never throws, zero-dep.
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
|
-
import { join, dirname, resolve } from 'node:path';
|
|
5
|
+
import { join, dirname, resolve, isAbsolute, posix } from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const gitOut = (repo, args) => execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
9
|
+
const validMandate = (m) => m && Array.isArray(m.paths) && m.paths.some((s) => typeof s === 'string' && s.trim());
|
|
10
|
+
const pathsOf = (m) => m.paths.filter((s) => typeof s === 'string' && s.trim());
|
|
6
11
|
|
|
7
12
|
// mandates accumulate down the `extends` chain; a local mandate of the same name overrides an inherited one
|
|
8
13
|
export function resolvedMandates(repo) {
|
|
@@ -16,7 +21,54 @@ export function resolvedMandates(repo) {
|
|
|
16
21
|
for (const m of (Array.isArray(cfg.mandates) ? cfg.mandates : [])) if (m && m.name) byName.set(String(m.name), m);
|
|
17
22
|
};
|
|
18
23
|
load(join(repo, 'promptwheel.config.json'));
|
|
19
|
-
return [...byName.values()].filter(
|
|
24
|
+
return [...byName.values()].filter(validMandate);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// the mandates COMMITTED at `ref` (default HEAD), read with `git show` and following `extends` inside
|
|
28
|
+
// the repo, the way promptwheel reads a gate at a ref. [] when there is no config there.
|
|
29
|
+
export function resolvedMandatesAt(repo, ref = 'HEAD') {
|
|
30
|
+
const seen = new Set(); const byName = new Map();
|
|
31
|
+
const load = (p) => {
|
|
32
|
+
if (seen.has(p)) return; seen.add(p);
|
|
33
|
+
let raw = null; try { raw = gitOut(repo, ['show', `${ref}:${p}`]); } catch { return; }
|
|
34
|
+
let cfg = null; try { cfg = JSON.parse(raw); } catch { return; }
|
|
35
|
+
if (!cfg || typeof cfg !== 'object') return;
|
|
36
|
+
const ext = cfg.extends == null ? [] : Array.isArray(cfg.extends) ? cfg.extends : [cfg.extends];
|
|
37
|
+
for (const e of ext) if (typeof e === 'string' && !isAbsolute(e)) load(posix.normalize(posix.join(posix.dirname(p), e.split('\\').join('/'))));
|
|
38
|
+
for (const m of (Array.isArray(cfg.mandates) ? cfg.mandates : [])) if (m && m.name) byName.set(String(m.name), m);
|
|
39
|
+
};
|
|
40
|
+
load('promptwheel.config.json');
|
|
41
|
+
return [...byName.values()].filter(validMandate);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// the rules in force for a turn: the working-tree contract, plus every committed mandate the working
|
|
45
|
+
// tree does not carry unchanged (same name, same paths). Editing the contract in the working tree
|
|
46
|
+
// does not lift a committed rule, so a change cannot disarm the wall and break it in one turn; a
|
|
47
|
+
// rule that comes only from the commit is tagged `committed`.
|
|
48
|
+
export function mandatesInForce(repo, ref = 'HEAD') {
|
|
49
|
+
const wt = resolvedMandates(repo);
|
|
50
|
+
const key = (m) => JSON.stringify([String(m.name), pathsOf(m).sort()]);
|
|
51
|
+
const have = new Set(wt.map(key));
|
|
52
|
+
const committed = resolvedMandatesAt(repo, ref).filter((m) => !have.has(key(m))).map((m) => ({ ...m, committed: true }));
|
|
53
|
+
return [...wt, ...committed];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// a working-tree file's content hash as git would store it; 'deleted' when it is gone
|
|
57
|
+
export function hashOf(repo, file) {
|
|
58
|
+
try { return gitOut(repo, ['hash-object', '--', file]).trim(); } catch { return 'deleted'; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// the protected paths that are dirty right now, each with its content hash: the baseline `turn`
|
|
62
|
+
// records when a prompt begins, so the Stop hook counts only what the turn itself changed and leaves
|
|
63
|
+
// a person's own pre-existing edits alone. {} when nothing protected is dirty (or not a git repo).
|
|
64
|
+
export function protectedDirty(repo) {
|
|
65
|
+
let changed = [];
|
|
66
|
+
try { changed = [...new Set([...gitOut(repo, ['diff', '--name-only', 'HEAD']).split('\n'), ...gitOut(repo, ['ls-files', '--others', '--exclude-standard']).split('\n')].filter(Boolean))]; }
|
|
67
|
+
catch { return {}; }
|
|
68
|
+
const res = mandatesInForce(repo).flatMap((m) => pathsOf(m).map(globToRe));
|
|
69
|
+
const out = {};
|
|
70
|
+
for (const f of changed) if (res.some((re) => re.test(f))) out[f] = hashOf(repo, f);
|
|
71
|
+
return out;
|
|
20
72
|
}
|
|
21
73
|
|
|
22
74
|
// the same glob dialect as promptwheel's evaluatorPaths / mandates (ported verbatim): a bare name
|
|
@@ -30,13 +82,18 @@ export function globToRe(glob) {
|
|
|
30
82
|
return new RegExp(`${pre}${body}$`);
|
|
31
83
|
}
|
|
32
84
|
|
|
33
|
-
// which changed paths violate which mandate: [{ name, reason, files }]
|
|
34
|
-
|
|
85
|
+
// which changed paths violate which mandate: [{ name, reason, files, committed? }]. The rules are the
|
|
86
|
+
// working tree's plus the committed ones (mandatesInForce); pass { ref: null } for the working tree alone.
|
|
87
|
+
export function mandateHits(repo, changedPaths, opts = {}) {
|
|
88
|
+
const list = opts.ref === null ? resolvedMandates(repo) : mandatesInForce(repo, opts.ref || 'HEAD');
|
|
35
89
|
const out = [];
|
|
36
|
-
for (const m of
|
|
37
|
-
const res = m
|
|
90
|
+
for (const m of list) {
|
|
91
|
+
const res = pathsOf(m).map(globToRe);
|
|
38
92
|
const files = changedPaths.filter((f) => res.some((re) => re.test(f)));
|
|
39
|
-
if (files.length)
|
|
93
|
+
if (!files.length) continue;
|
|
94
|
+
const prev = out.find((h) => h.name === String(m.name));
|
|
95
|
+
if (prev) { prev.files = [...new Set([...prev.files, ...files])].slice(0, 20); if (!prev.reason && m.reason) prev.reason = String(m.reason).slice(0, 300); continue; }
|
|
96
|
+
out.push({ name: String(m.name), reason: m.reason ? String(m.reason).slice(0, 300) : '', files: files.slice(0, 20), ...(m.committed ? { committed: true } : {}) });
|
|
40
97
|
}
|
|
41
98
|
return out;
|
|
42
99
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
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 } from './contract.mjs';
|
|
13
|
+
import { mandateHits, hashOf } 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
|
|
@@ -50,20 +50,28 @@ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFile
|
|
|
50
50
|
// 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
|
|
51
51
|
// tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
|
|
52
52
|
// edits. A deterministic path match, milliseconds, no metric run. Block the stop (bounded per prompt)
|
|
53
|
-
// and send the agent back to
|
|
53
|
+
// and send the agent back to undo its protected edits and make the fix elsewhere. The rules are the
|
|
54
|
+
// working tree's AND the committed contract's (editing the config in the same turn lifts nothing),
|
|
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.
|
|
54
57
|
if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
55
|
-
const
|
|
58
|
+
const dirty = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
|
|
59
|
+
const baseline = (st.turnBaseline && st.turnBaseline.files && typeof st.turnBaseline.files === 'object') ? st.turnBaseline.files : {};
|
|
60
|
+
const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashOf(repo, f) === baseline[f];
|
|
61
|
+
const changed = dirty.filter((f) => !untouched(f));
|
|
62
|
+
const leftAlone = dirty.filter(untouched);
|
|
56
63
|
const hits = changed.length ? mandateHits(repo, changed) : [];
|
|
57
64
|
if (hits.length) {
|
|
58
65
|
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
59
66
|
const promptId = hookInput.prompt_id || 'none';
|
|
60
67
|
if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
|
|
61
|
-
const desc = hits.map((h) => `"${h.name}"${h.reason ? ' (' + h.reason + ')' : ''}: ${h.files.join(', ')}`).join('; ');
|
|
68
|
+
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
|
+
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(', ')}.` : '';
|
|
62
70
|
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 {}
|
|
63
71
|
if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
|
|
64
72
|
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
65
73
|
saveState();
|
|
66
|
-
process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a mandate in the contract was violated, the turn is not done. Protected paths changed: ${desc}.
|
|
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}` }));
|
|
67
75
|
process.exit(0);
|
|
68
76
|
}
|
|
69
77
|
saveState();
|
package/sawbuck.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { recall as memRecall, conform as memConform } from './memory.mjs';
|
|
21
21
|
import { PW } from './bins.mjs';
|
|
22
|
-
import { frameBlock } from './contract.mjs';
|
|
22
|
+
import { frameBlock, mandatesInForce, protectedDirty } from './contract.mjs';
|
|
23
23
|
|
|
24
24
|
const SELF = fileURLToPath(import.meta.url);
|
|
25
25
|
const HERE = dirname(SELF);
|
|
@@ -206,6 +206,21 @@ switch (cmd) {
|
|
|
206
206
|
// (backward: what broke here before) in a single block, one process per prompt. Silent when
|
|
207
207
|
// there is nothing to say. Wired by `attach --claude` on UserPromptSubmit and after a compaction.
|
|
208
208
|
const repo = repoArg();
|
|
209
|
+
// When a PROMPT begins (the UserPromptSubmit hook, never the SessionStart[compact] one, which
|
|
210
|
+
// fires mid-turn), remember which protected paths were already dirty and what they held, so the
|
|
211
|
+
// Stop hook's wall counts only what this turn changes and leaves a person's own edits alone.
|
|
212
|
+
let hookEvent = '';
|
|
213
|
+
if (!process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
214
|
+
if (hookEvent === 'UserPromptSubmit') {
|
|
215
|
+
try {
|
|
216
|
+
if (mandatesInForce(repo).length) {
|
|
217
|
+
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
218
|
+
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
219
|
+
st.turnBaseline = { at: new Date().toISOString(), files: protectedDirty(repo) };
|
|
220
|
+
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
221
|
+
}
|
|
222
|
+
} catch { /* the frame never fails a prompt */ }
|
|
223
|
+
}
|
|
209
224
|
const o = [frameBlock(repo), contextBlock(repo, false)].filter(Boolean).join('\n');
|
|
210
225
|
if (o) out(o);
|
|
211
226
|
break;
|
|
@@ -444,12 +459,19 @@ function wireClaude(repo, opts) {
|
|
|
444
459
|
const fileExisted = existsSync(f);
|
|
445
460
|
let cfg = {};
|
|
446
461
|
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.`); } }
|
|
447
|
-
if (JSON.stringify(cfg).includes('sawbuck.mjs')) { out(`sawbuck: already attached (${user ? 'user' : 'claude'}) in ${f}`); return; }
|
|
448
462
|
const ctx = user ? `node ${JSON.stringify(SELF)} turn` : `node ${JSON.stringify(SELF)} turn ${JSON.stringify(repo)}`;
|
|
449
463
|
const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
|
|
464
|
+
// attached already? "already attached" only when every hook of THIS version is present; an older
|
|
465
|
+
// attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
|
|
466
|
+
let upgrading = false;
|
|
467
|
+
if (JSON.stringify(cfg).includes('sawbuck.mjs')) {
|
|
468
|
+
const has = (event, command, matcher) => ((cfg.hooks || {})[event] || []).some((h) => h && (matcher ? h.matcher === matcher : !h.matcher) && Array.isArray(h.hooks) && h.hooks.some((x) => x && x.command === command));
|
|
469
|
+
if (has('UserPromptSubmit', ctx) && has('SessionStart', ctx, 'compact') && has('Stop', chk)) { out(`sawbuck: already attached (${user ? 'user' : 'claude'}) in ${f}`); return; }
|
|
470
|
+
upgrading = true;
|
|
471
|
+
}
|
|
450
472
|
if (dry) {
|
|
451
473
|
out(dryText([
|
|
452
|
-
`${fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
|
|
474
|
+
`${upgrading ? 'update' : fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
|
|
453
475
|
user ? null : `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')}${shared ? '' : ', .claude/settings.local.json'})`,
|
|
454
476
|
`create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
|
|
455
477
|
].filter(Boolean)));
|
|
@@ -470,11 +492,13 @@ function wireClaude(repo, opts) {
|
|
|
470
492
|
const mode = user ? 'user' : shared ? 'claude-shared' : 'claude';
|
|
471
493
|
const mp = user ? userManifestPath() : manifestPath(repo);
|
|
472
494
|
const m = readManifest(mp);
|
|
473
|
-
|
|
495
|
+
const prev = (m.attachments || {})[mode]; // an upgrade keeps the receipt of what the first attach created
|
|
496
|
+
m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: prev ? !!prev.fileCreated : !fileExisted, dirCreated: prev ? !!prev.dirCreated : !dirExisted } };
|
|
474
497
|
if (!user) syncExclude(repo, m);
|
|
475
498
|
writeManifest(mp, m);
|
|
476
499
|
regAdd(repo, mode);
|
|
477
|
-
out(`sawbuck:
|
|
500
|
+
out(upgrading ? `sawbuck: updated the ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f} to this version`
|
|
501
|
+
: `sawbuck: wired ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f}`);
|
|
478
502
|
if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
|
|
479
503
|
else if (!shared) out(' (settings.local.json is gitignored; use --shared to commit the hooks for your team)');
|
|
480
504
|
}
|