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 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.25",
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
- // the read face: ranked relevant history for what you're working on right now.
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 g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
193
- // Silent when there is nothing to say, so it is safe on every UserPromptSubmit (that
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)} context` : `node ${JSON.stringify(SELF)} context ${JSON.stringify(repo)}`;
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