sawbuck 0.1.25 → 0.1.26
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/README.md +4 -0
- package/contract.mjs +50 -0
- package/memory.mjs +1 -1
- package/package.json +2 -1
- package/sawbuck-hook.mjs +27 -1
- package/sawbuck.mjs +45 -21
package/README.md
CHANGED
|
@@ -56,6 +56,8 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
|
|
|
56
56
|
| `sawbuck status` · `sawbuck log` | the inventory (caught over time + debt now + armed state), in one view; and the recent per-check feed |
|
|
57
57
|
| `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
|
|
58
58
|
| `sawbuck context` | the ranked relevant history for the current change |
|
|
59
|
+
| `sawbuck frame` | the contract's hard rules (mandates, inherited ones included) as one compact block; silent without a contract |
|
|
60
|
+
| `sawbuck turn` | frame + context in one block: what `attach --claude` runs on every prompt and again right after a compaction |
|
|
59
61
|
| `sawbuck recall` | related notes for your current change from extra memory providers you compose in (bring your own) |
|
|
60
62
|
| `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
|
|
61
63
|
| `sawbuck where` · `sawbuck statusline` | list every repo it is attached to; a one-line status-line segment (🐴 watching / N caught / steer %) |
|
|
@@ -70,6 +72,8 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
|
|
|
70
72
|
Run `sawbuck context` first to see what broke in these files before (reverts, weakened tests,
|
|
71
73
|
fragile spots). Never skip, weaken, or delete a test to go green.
|
|
72
74
|
|
|
75
|
+
**Measured, not assumed.** On a scenario whose natural fix sits inside a protected module, driven past two forced compactions (and again on Haiku, and under "urgent hotfix" pressure): with no rule the agent edited the protected file 17 of 17 times; with a rule in any form, including a mandate, 0 of 45. The model honors a rule it was told. The gate is for the rule nobody told it, the session that outlived the summary, and the proof that it held: `attach --claude` re-reads the mandates from the repo on every turn, re-impresses them after a compaction (`sawbuck turn`), and blocks a turn that leaves a protected path changed. Numbers and method: [sawbuck.ai/contracts](https://sawbuck.ai/contracts).
|
|
76
|
+
|
|
73
77
|
Without a `promptwheel.config.json` the check measures your detected test command. To make it count more (coverage, lint, latency, an eval) or to protect a path with a mandate, `sawbuck init` writes the config and explains each line with evidence from your history.
|
|
74
78
|
|
|
75
79
|
## Gate debt on pull requests
|
package/contract.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// contract.mjs: the contract's hard rules, read the way promptwheel reads them. Shared by the CLI
|
|
2
|
+
// (frame / turn) and the Stop hook (the turn-level wall), so there is one resolver and one renderer.
|
|
3
|
+
// Read-only, never throws, zero-dep.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join, dirname, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
// mandates accumulate down the `extends` chain; a local mandate of the same name overrides an inherited one
|
|
8
|
+
export function resolvedMandates(repo) {
|
|
9
|
+
const seen = new Set(); const byName = new Map();
|
|
10
|
+
const load = (file) => {
|
|
11
|
+
const p = resolve(file); if (seen.has(p)) return; seen.add(p);
|
|
12
|
+
let cfg = null; try { cfg = JSON.parse(readFileSync(p, 'utf8')); } catch { return; }
|
|
13
|
+
if (!cfg || typeof cfg !== 'object') return;
|
|
14
|
+
const ext = cfg.extends == null ? [] : Array.isArray(cfg.extends) ? cfg.extends : [cfg.extends];
|
|
15
|
+
for (const e of ext) if (typeof e === 'string') load(resolve(dirname(p), e));
|
|
16
|
+
for (const m of (Array.isArray(cfg.mandates) ? cfg.mandates : [])) if (m && m.name) byName.set(String(m.name), m);
|
|
17
|
+
};
|
|
18
|
+
load(join(repo, 'promptwheel.config.json'));
|
|
19
|
+
return [...byName.values()].filter((m) => Array.isArray(m.paths) && m.paths.some((s) => typeof s === 'string' && s.trim()));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// the same glob dialect as promptwheel's evaluatorPaths / mandates (ported verbatim): a bare name
|
|
23
|
+
// matches that basename at any depth; a pattern with "/" is anchored at the repo root; "*" stops at
|
|
24
|
+
// "/", "**" crosses it, "?" is one char; a leading "/" anchors to the root explicitly.
|
|
25
|
+
export function globToRe(glob) {
|
|
26
|
+
let g = glob; const anchored = g.startsWith('/'); if (anchored) g = g.slice(1);
|
|
27
|
+
const body = g.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
28
|
+
.replace(/\*\*/g, '\x00').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\x00/g, '.*');
|
|
29
|
+
const pre = (anchored || glob.includes('/')) ? '^' : '(^|/)';
|
|
30
|
+
return new RegExp(`${pre}${body}$`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// which changed paths violate which mandate: [{ name, reason, files }]
|
|
34
|
+
export function mandateHits(repo, changedPaths) {
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const m of resolvedMandates(repo)) {
|
|
37
|
+
const res = m.paths.filter((s) => typeof s === 'string' && s.trim()).map(globToRe);
|
|
38
|
+
const files = changedPaths.filter((f) => res.some((re) => re.test(f)));
|
|
39
|
+
if (files.length) out.push({ name: String(m.name), reason: m.reason ? String(m.reason).slice(0, 300) : '', files: files.slice(0, 20) });
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// the prompt face of the contract: one compact block, or '' when there is no contract
|
|
45
|
+
export function frameBlock(repo) {
|
|
46
|
+
const mandates = resolvedMandates(repo);
|
|
47
|
+
if (!mandates.length) return '';
|
|
48
|
+
const lines = mandates.map((m) => ` ${m.paths.filter((s) => typeof s === 'string' && s.trim()).join(' · ')}${m.reason ? ` (${String(m.reason).slice(0, 160)})` : ''}`);
|
|
49
|
+
return '▣ contract (sawbuck) · hard rules in force this session, enforced by the gate on every turn:\n protected, do not touch:\n' + lines.join('\n');
|
|
50
|
+
}
|
package/memory.mjs
CHANGED
|
@@ -96,7 +96,7 @@ const classify = (r, source) => ({
|
|
|
96
96
|
file: r.file, line: r.line, kind: String(r.kind ?? '').trim(), content: r.content ?? '',
|
|
97
97
|
since: r.since ?? null, sha: r.sha ?? null,
|
|
98
98
|
documented: r.content ? isDocumented(r.content) : false,
|
|
99
|
-
testSkip: isTestSkip(r.kind ?? r.content ?? ''),
|
|
99
|
+
testSkip: typeof r.testSkip === 'boolean' ? r.testSkip : isTestSkip(r.kind ?? r.content ?? ''), // logbook >=0.9.8 classifies it; the regex is the fallback for older providers
|
|
100
100
|
source,
|
|
101
101
|
});
|
|
102
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"sawbuck.mjs",
|
|
41
41
|
"bins.mjs",
|
|
42
|
+
"contract.mjs",
|
|
42
43
|
"init.mjs",
|
|
43
44
|
"reflect.mjs",
|
|
44
45
|
"dispatch.mjs",
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
// Zero-dep. Usage (wired as a Stop hook): node sawbuck-hook.mjs --repo <path>
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from 'node:fs';
|
|
9
9
|
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
14
|
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
|
|
14
15
|
const repo = flag('--repo', process.cwd());
|
|
15
16
|
const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
|
|
@@ -46,6 +47,31 @@ try { rec = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } cat
|
|
|
46
47
|
const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
|
|
47
48
|
const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
|
|
48
49
|
|
|
50
|
+
// 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
|
|
51
|
+
// tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
|
|
52
|
+
// edits. A deterministic path match, milliseconds, no metric run. Block the stop (bounded per prompt)
|
|
53
|
+
// and send the agent back to revert the protected files and make the fix elsewhere.
|
|
54
|
+
if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
55
|
+
const changed = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
|
|
56
|
+
const hits = changed.length ? mandateHits(repo, changed) : [];
|
|
57
|
+
if (hits.length) {
|
|
58
|
+
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
59
|
+
const promptId = hookInput.prompt_id || 'none';
|
|
60
|
+
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('; ');
|
|
62
|
+
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
|
+
if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
|
|
64
|
+
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
65
|
+
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}. Revert every change under the protected paths and make the fix elsewhere (the rule is a wall, not a reminder; the gate fails any change that touches them).` }));
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
saveState();
|
|
70
|
+
process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: protected paths are still changed (${desc}).` }));
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
49
75
|
// 3) CONTRACT ENFORCEMENT (Claude Code Stop hook only): if THIS turn's commit was gamed,
|
|
50
76
|
// block the stop and send the agent back to earn the green in the source. Bounded per
|
|
51
77
|
// prompt, because Claude Code's built-in block cap resets on every tool call and so cannot
|
package/sawbuck.mjs
CHANGED
|
@@ -19,6 +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
23
|
|
|
23
24
|
const SELF = fileURLToPath(import.meta.url);
|
|
24
25
|
const HERE = dirname(SELF);
|
|
@@ -81,6 +82,9 @@ Usage:
|
|
|
81
82
|
The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
|
|
82
83
|
shows only what a branch adds; --fail-on exits nonzero (a CI gate)
|
|
83
84
|
sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
|
|
85
|
+
sawbuck frame [repo] The contract's hard rules (mandates, inherited ones included) as one compact block;
|
|
86
|
+
silent without a contract (--speak explains)
|
|
87
|
+
sawbuck turn [repo] frame + context in one block: what attach --claude runs on every prompt
|
|
84
88
|
|
|
85
89
|
sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
|
|
86
90
|
sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
|
|
@@ -186,23 +190,23 @@ switch (cmd) {
|
|
|
186
190
|
catch (e) { process.exit(e.status || 1); }
|
|
187
191
|
break;
|
|
188
192
|
}
|
|
189
|
-
case 'context': {
|
|
190
|
-
|
|
193
|
+
case 'context': { const o = contextBlock(repoArg(), flags.has('--speak')); if (o) out(o); break; }
|
|
194
|
+
case 'frame': {
|
|
195
|
+
// the prompt face of the contract: re-impress the hard rules (mandates, resolved through `extends`)
|
|
196
|
+
// as one compact block at the live end of the context, so a deep or freshly compacted session
|
|
197
|
+
// cannot forget them. Silent without a contract; --speak explains instead.
|
|
198
|
+
const o = frameBlock(repoArg());
|
|
199
|
+
if (o) out(o);
|
|
200
|
+
else if (flags.has('--speak')) out('🐴 sawbuck frame: no mandates in promptwheel.config.json yet. Add one (a protected path) and this re-asserts it every turn and after every compaction, so a deep context cannot forget it.');
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case 'turn': {
|
|
204
|
+
// the ONE per-prompt hook: the contract frame (forward: what must hold) and the relevant history
|
|
205
|
+
// (backward: what broke here before) in a single block, one process per prompt. Silent when
|
|
206
|
+
// there is nothing to say. Wired by `attach --claude` on UserPromptSubmit and after a compaction.
|
|
191
207
|
const repo = repoArg();
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
// hook's stdout is injected into context; "nothing notable" every turn would be noise).
|
|
195
|
-
// --speak forces a reply, for an explicit `sawbuck context` / the /sawbuck-context command.
|
|
196
|
-
const speak = flags.has('--speak');
|
|
197
|
-
let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
|
|
198
|
-
if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
|
|
199
|
-
if (!changed.length) { if (speak) out('🐴 sawbuck: nothing changed to look up (no working edits, and no previous commit to read).'); break; }
|
|
200
|
-
const fileArgs = changed.flatMap((f) => ['--file', f]);
|
|
201
|
-
let o = '';
|
|
202
|
-
try { o = execFileSync(process.execPath, [join(HERE, 'rank.mjs'), '--repo', repo, ...fileArgs, '--top', '3'], { encoding: 'utf8' }); }
|
|
203
|
-
catch (e) { o = (e.stdout || '').toString(); }
|
|
204
|
-
if (/relevant history/.test(o)) out(o);
|
|
205
|
-
else if (speak) out('🐴 sawbuck: no notable history for the files you are touching (a clean trail).');
|
|
208
|
+
const o = [frameBlock(repo), contextBlock(repo, false)].filter(Boolean).join('\n');
|
|
209
|
+
if (o) out(o);
|
|
206
210
|
break;
|
|
207
211
|
}
|
|
208
212
|
case 'recall': {
|
|
@@ -304,6 +308,23 @@ switch (cmd) {
|
|
|
304
308
|
default: die(`unknown command '${cmd}'\n\n${help}`);
|
|
305
309
|
}
|
|
306
310
|
|
|
311
|
+
// --- the per-turn block: frame (forward, from contract.mjs) + context (backward) ---------------
|
|
312
|
+
// the read face: ranked relevant history for what you're working on right now. Silent when there is
|
|
313
|
+
// nothing to say, so it is safe on every UserPromptSubmit (that hook's stdout is injected into context;
|
|
314
|
+
// "nothing notable" every turn would be noise). `speak` forces a reply for the explicit command.
|
|
315
|
+
function contextBlock(repo, speak) {
|
|
316
|
+
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
317
|
+
let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
|
|
318
|
+
if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
|
|
319
|
+
if (!changed.length) return speak ? '🐴 sawbuck: nothing changed to look up (no working edits, and no previous commit to read).' : '';
|
|
320
|
+
const fileArgs = changed.flatMap((f) => ['--file', f]);
|
|
321
|
+
let o = '';
|
|
322
|
+
try { o = execFileSync(process.execPath, [join(HERE, 'rank.mjs'), '--repo', repo, ...fileArgs, '--top', '3'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); }
|
|
323
|
+
catch (e) { o = (e.stdout || '').toString(); }
|
|
324
|
+
if (/relevant history/.test(o)) return o.trimEnd();
|
|
325
|
+
return speak ? '🐴 sawbuck: no notable history for the files you are touching (a clean trail).' : '';
|
|
326
|
+
}
|
|
327
|
+
|
|
307
328
|
// after attach, prove the check can actually measure something here (the inert-metric trap):
|
|
308
329
|
// run the gate on the last commit and say what it can or cannot measure, so you never attach
|
|
309
330
|
// into silent nothing. Best-effort and bounded; the wire already succeeded regardless.
|
|
@@ -423,22 +444,25 @@ function wireClaude(repo, opts) {
|
|
|
423
444
|
let cfg = {};
|
|
424
445
|
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.`); } }
|
|
425
446
|
if (JSON.stringify(cfg).includes('sawbuck.mjs')) { out(`sawbuck: already attached (${user ? 'user' : 'claude'}) in ${f}`); return; }
|
|
426
|
-
const ctx = user ? `node ${JSON.stringify(SELF)}
|
|
447
|
+
const ctx = user ? `node ${JSON.stringify(SELF)} turn` : `node ${JSON.stringify(SELF)} turn ${JSON.stringify(repo)}`;
|
|
427
448
|
const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
|
|
428
449
|
if (dry) {
|
|
429
450
|
out(dryText([
|
|
430
|
-
`${fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + Stop hooks)`,
|
|
451
|
+
`${fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
|
|
431
452
|
user ? null : `exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')}${shared ? '' : ', .claude/settings.local.json'})`,
|
|
432
453
|
`create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
|
|
433
454
|
].filter(Boolean)));
|
|
434
455
|
return;
|
|
435
456
|
}
|
|
436
457
|
cfg.hooks = cfg.hooks || {};
|
|
437
|
-
const put = (event, command) => {
|
|
458
|
+
const put = (event, command, matcher) => {
|
|
438
459
|
cfg.hooks[event] = (cfg.hooks[event] || []).filter((h) => !JSON.stringify(h).includes('sawbuck.mjs'));
|
|
439
|
-
cfg.hooks[event].push({ hooks: [{ type: 'command', command }] });
|
|
460
|
+
cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
|
|
440
461
|
};
|
|
441
462
|
put('UserPromptSubmit', ctx);
|
|
463
|
+
// right after a compaction the summary has just dropped detail: re-impress the contract frame and the
|
|
464
|
+
// relevant history there too (SessionStart with the `compact` source injects stdout into context).
|
|
465
|
+
put('SessionStart', ctx, 'compact');
|
|
442
466
|
put('Stop', chk);
|
|
443
467
|
mkdirSync(dir, { recursive: true });
|
|
444
468
|
writeFileSync(f, JSON.stringify(cfg, null, 2) + '\n');
|
|
@@ -482,7 +506,7 @@ function unwireClaude(repo, { user }) {
|
|
|
482
506
|
}
|
|
483
507
|
function removeClaudeEntries(cfg) {
|
|
484
508
|
if (!cfg.hooks) return;
|
|
485
|
-
for (const ev of ['UserPromptSubmit', 'Stop']) {
|
|
509
|
+
for (const ev of ['UserPromptSubmit', 'SessionStart', 'Stop']) {
|
|
486
510
|
if (!Array.isArray(cfg.hooks[ev])) continue;
|
|
487
511
|
cfg.hooks[ev] = cfg.hooks[ev].filter((h) => !JSON.stringify(h).includes('sawbuck.mjs'));
|
|
488
512
|
if (!cfg.hooks[ev].length) delete cfg.hooks[ev]; // no empty [] residue
|