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 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((m) => Array.isArray(m.paths) && m.paths.some((s) => typeof s === 'string' && s.trim()));
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
- export function mandateHits(repo, changedPaths) {
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 resolvedMandates(repo)) {
37
- const res = m.paths.filter((s) => typeof s === 'string' && s.trim()).map(globToRe);
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) out.push({ name: String(m.name), reason: m.reason ? String(m.reason).slice(0, 300) : '', files: files.slice(0, 20) });
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.26",
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 revert the protected files and make the fix elsewhere.
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 changed = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
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}. 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).` }));
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
- const positional = rest.filter((a) => !a.startsWith('--'));
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
- m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: !fileExisted, dirCreated: !dirExisted } };
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: wired ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f}`);
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
  }