sawbuck 0.1.28 → 0.1.30

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
@@ -53,6 +53,12 @@ export function mandatesInForce(repo, ref = 'HEAD') {
53
53
  return [...wt, ...committed];
54
54
  }
55
55
 
56
+ // the paths among `paths` that a mandate in force covers (any rule, working tree or committed)
57
+ export function protectedAmong(repo, paths) {
58
+ const res = mandatesInForce(repo).flatMap((m) => pathsOf(m).map(globToRe));
59
+ return paths.filter((f) => res.some((re) => re.test(f)));
60
+ }
61
+
56
62
  // a working-tree file's content hash as git would store it; 'deleted' when it is gone
57
63
  export function hashOf(repo, file) {
58
64
  try { return gitOut(repo, ['hash-object', '--', file]).trim(); } catch { return 'deleted'; }
@@ -92,7 +98,12 @@ export function mandateHits(repo, changedPaths, opts = {}) {
92
98
  const files = changedPaths.filter((f) => res.some((re) => re.test(f)));
93
99
  if (!files.length) continue;
94
100
  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; }
101
+ if (prev) {
102
+ prev.files = [...new Set([...prev.files, ...files])].slice(0, 20);
103
+ if (!prev.reason && m.reason) prev.reason = String(m.reason).slice(0, 300);
104
+ if (m.committed) prev.committed = true; // the committed version of the rule hit too: say so
105
+ continue;
106
+ }
96
107
  out.push({ name: String(m.name), reason: m.reason ? String(m.reason).slice(0, 300) : '', files: files.slice(0, 20), ...(m.committed ? { committed: true } : {}) });
97
108
  }
98
109
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
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, hashOf } from './contract.mjs';
13
+ import { mandateHits, hashOf, protectedDirty, protectedAmong } 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
@@ -53,25 +53,40 @@ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFile
53
53
  // and send the agent back to undo its protected edits and make the fix elsewhere. The rules are the
54
54
  // working tree's AND the committed contract's (editing the config in the same turn lifts nothing),
55
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.
56
+ // baseline `sawbuck turn --hook` recorded) is the person's own work and does not count. A protected
57
+ // edit COMMITTED since the baseline's commit counts too: a commit mid-turn does not walk through.
57
58
  if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
59
+ const now = new Date().toISOString();
58
60
  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));
61
+ const base = (st.turnBaseline && typeof st.turnBaseline === 'object') ? st.turnBaseline : null;
62
+ const baseline = (base && base.files && typeof base.files === 'object') ? base.files : {};
63
+ const committedSince = (base && base.sha && head && base.sha !== head) ? git('diff', '--name-only', base.sha, 'HEAD').split('\n').filter(Boolean) : [];
64
+ const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
65
+ const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
66
+ const changed = [...new Set([...dirty.filter((f) => !untouched(f)), ...committedSince])];
62
67
  const leftAlone = dirty.filter(untouched);
63
68
  const hits = changed.length ? mandateHits(repo, changed) : [];
69
+ st.lastStopAt = now; // the next prompt may take a fresh baseline only because this stop happened
70
+ if (hits.length) {
71
+ // remember EVERY changed protected path (dirty or committed since the baseline) and what it holds,
72
+ // uncapped, and the commit the baseline stood at, so the next prompt cannot adopt any of it
73
+ const files = protectedDirty(repo);
74
+ for (const f of protectedAmong(repo, committedSince)) if (!Object.prototype.hasOwnProperty.call(files, f)) files[f] = hashOf(repo, f);
75
+ st.lastViolation = { at: now, sha: (base && base.sha) || null, files };
76
+ } else if (st.lastViolation) { delete st.lastViolation; }
77
+ saveState();
64
78
  if (hits.length) {
65
79
  const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
66
80
  const promptId = hookInput.prompt_id || 'none';
67
81
  if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
68
82
  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
83
  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(', ')}.` : '';
84
+ const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach --claude again, which upgrades the hooks.)';
70
85
  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 {}
71
86
  if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
72
87
  st.mandateBlocks = (st.mandateBlocks || 0) + 1;
73
88
  saveState();
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}` }));
89
+ 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}${upgrade}` }));
75
90
  process.exit(0);
76
91
  }
77
92
  saveState();
package/sawbuck.mjs CHANGED
@@ -209,14 +209,31 @@ switch (cmd) {
209
209
  // When a PROMPT begins (the UserPromptSubmit hook, never the SessionStart[compact] one, which
210
210
  // fires mid-turn), remember which protected paths were already dirty and what they held, so the
211
211
  // Stop hook's wall counts only what this turn changes and leaves a person's own edits alone.
212
+ // Only under --hook (the wiring passes it) is stdin read for the hook's JSON, so a plain
213
+ // `sawbuck turn` never waits on a pipe. Three rules keep the baseline honest: a new baseline is
214
+ // taken only when the previous turn actually reached a Stop (an interrupt, a crash, or a re-run of
215
+ // this command mid-turn leaves the last one standing, so nothing the agent left behind is adopted);
216
+ // a file still carrying the content the last violation flagged is never baselined; and the
217
+ // baseline's commit does not advance past a flagged commit until a clean stop.
212
218
  let hookEvent = '';
213
- if (!process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
219
+ if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
214
220
  if (hookEvent === 'UserPromptSubmit') {
215
221
  try {
216
222
  if (mandatesInForce(repo).length) {
217
223
  const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
218
224
  let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
219
- st.turnBaseline = { at: new Date().toISOString(), files: protectedDirty(repo) };
225
+ const now = new Date().toISOString();
226
+ const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
227
+ const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
228
+ const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
229
+ if (!unfinished) {
230
+ const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
231
+ const files = protectedDirty(repo);
232
+ for (const f of Object.keys(files)) if (flagged[f] === files[f]) delete files[f]; // the agent's un-reverted edit stays on the wall
233
+ let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
234
+ st.turnBaseline = { at: now, sha: (st.lastViolation && st.lastViolation.sha) || sha, files };
235
+ }
236
+ st.lastPromptAt = now;
220
237
  mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
221
238
  }
222
239
  } catch { /* the frame never fails a prompt */ }
@@ -459,7 +476,7 @@ function wireClaude(repo, opts) {
459
476
  const fileExisted = existsSync(f);
460
477
  let cfg = {};
461
478
  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.`); } }
462
- const ctx = user ? `node ${JSON.stringify(SELF)} turn` : `node ${JSON.stringify(SELF)} turn ${JSON.stringify(repo)}`;
479
+ const ctx = user ? `node ${JSON.stringify(SELF)} turn --hook` : `node ${JSON.stringify(SELF)} turn --hook ${JSON.stringify(repo)}`;
463
480
  const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
464
481
  // attached already? "already attached" only when every hook of THIS version is present; an older
465
482
  // attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.