sawbuck 0.1.26 → 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 +31 -6
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);
|
|
@@ -49,7 +49,8 @@ const argv = process.argv.slice(2);
|
|
|
49
49
|
const cmd = argv[0];
|
|
50
50
|
const rest = argv.slice(1);
|
|
51
51
|
const flags = new Set(rest.filter((a) => a.startsWith('--')));
|
|
52
|
-
|
|
52
|
+
// `-n N` (sawbuck log) is a flag with a value, not a repo path.
|
|
53
|
+
const positional = rest.filter((a, i) => !a.startsWith('--') && a !== '-n' && rest[i - 1] !== '-n');
|
|
53
54
|
const repoArg = () => resolve(positional[0] || '.');
|
|
54
55
|
const dry = flags.has('--dry-run');
|
|
55
56
|
|
|
@@ -205,6 +206,21 @@ switch (cmd) {
|
|
|
205
206
|
// (backward: what broke here before) in a single block, one process per prompt. Silent when
|
|
206
207
|
// there is nothing to say. Wired by `attach --claude` on UserPromptSubmit and after a compaction.
|
|
207
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
|
+
}
|
|
208
224
|
const o = [frameBlock(repo), contextBlock(repo, false)].filter(Boolean).join('\n');
|
|
209
225
|
if (o) out(o);
|
|
210
226
|
break;
|
|
@@ -443,12 +459,19 @@ function wireClaude(repo, opts) {
|
|
|
443
459
|
const fileExisted = existsSync(f);
|
|
444
460
|
let cfg = {};
|
|
445
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.`); } }
|
|
446
|
-
if (JSON.stringify(cfg).includes('sawbuck.mjs')) { out(`sawbuck: already attached (${user ? 'user' : 'claude'}) in ${f}`); return; }
|
|
447
462
|
const ctx = user ? `node ${JSON.stringify(SELF)} turn` : `node ${JSON.stringify(SELF)} turn ${JSON.stringify(repo)}`;
|
|
448
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
|
+
}
|
|
449
472
|
if (dry) {
|
|
450
473
|
out(dryText([
|
|
451
|
-
`${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)`,
|
|
452
475
|
user ? null : `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')}${shared ? '' : ', .claude/settings.local.json'})`,
|
|
453
476
|
`create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
|
|
454
477
|
].filter(Boolean)));
|
|
@@ -469,11 +492,13 @@ function wireClaude(repo, opts) {
|
|
|
469
492
|
const mode = user ? 'user' : shared ? 'claude-shared' : 'claude';
|
|
470
493
|
const mp = user ? userManifestPath() : manifestPath(repo);
|
|
471
494
|
const m = readManifest(mp);
|
|
472
|
-
|
|
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 } };
|
|
473
497
|
if (!user) syncExclude(repo, m);
|
|
474
498
|
writeManifest(mp, m);
|
|
475
499
|
regAdd(repo, mode);
|
|
476
|
-
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}`);
|
|
477
502
|
if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
|
|
478
503
|
else if (!shared) out(' (settings.local.json is gitignored; use --shared to commit the hooks for your team)');
|
|
479
504
|
}
|