sawbuck 0.1.29 → 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'; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.29",
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
@@ -46,23 +46,6 @@ let rec = null;
46
46
  try { rec = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch {}
47
47
  const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
48
48
  const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
49
- // when the last prompt of this session was typed, from the transcript the hook names (null when unknown):
50
- // the newest user row that carries text, not a tool result; the tail of the file is enough
51
- function lastPromptTime(transcriptPath) {
52
- try {
53
- if (!transcriptPath || !existsSync(transcriptPath)) return null;
54
- const text = readFileSync(transcriptPath, 'utf8'); const tail = text.length > 524288 ? text.slice(-524288) : text;
55
- const lines = tail.split('\n');
56
- for (let i = lines.length - 1; i >= 0; i--) {
57
- let o; try { o = JSON.parse(lines[i]); } catch { continue; }
58
- if (!o || o.type !== 'user' || !o.timestamp) continue;
59
- const c = o.message && o.message.content;
60
- const isText = typeof c === 'string' || (Array.isArray(c) && c.some((p) => p && p.type === 'text') && !c.some((p) => p && p.type === 'tool_result'));
61
- if (isText) { const t = Date.parse(o.timestamp); return Number.isFinite(t) ? t : null; }
62
- }
63
- } catch {}
64
- return null;
65
- }
66
49
 
67
50
  // 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
68
51
  // tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
@@ -70,34 +53,40 @@ function lastPromptTime(transcriptPath) {
70
53
  // and send the agent back to undo its protected edits and make the fix elsewhere. The rules are the
71
54
  // working tree's AND the committed contract's (editing the config in the same turn lifts nothing),
72
55
  // and a protected file that was already dirty with the same content when the prompt began (the
73
- // 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.
74
58
  if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
59
+ const now = new Date().toISOString();
75
60
  const dirty = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
76
- // the baseline `sawbuck turn --hook` recorded when the prompt began; one recorded well AFTER the
77
- // transcript's last prompt was not the prompt's (something re-ran `turn` mid-turn) and is ignored
78
- let baseline = (st.turnBaseline && st.turnBaseline.files && typeof st.turnBaseline.files === 'object') ? st.turnBaseline.files : {};
79
- const promptAt = lastPromptTime(hookInput.transcript_path);
80
- const baseAt = Date.parse(st.turnBaseline && st.turnBaseline.at);
81
- if (promptAt && baseAt && baseAt > promptAt + 30000) baseline = {};
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) : [];
82
64
  const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
83
65
  const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
84
- const changed = dirty.filter((f) => !untouched(f));
66
+ const changed = [...new Set([...dirty.filter((f) => !untouched(f)), ...committedSince])];
85
67
  const leftAlone = dirty.filter(untouched);
86
68
  const hits = changed.length ? mandateHits(repo, changed) : [];
87
- // remember what was flagged and what it held, so the next prompt's baseline cannot adopt it
88
- if (hits.length) { const files = {}; for (const h of hits) for (const f of h.files) files[f] = hashOf(repo, f); st.lastViolation = { at: new Date().toISOString(), files }; }
89
- else if (st.lastViolation) { delete st.lastViolation; saveState(); }
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();
90
78
  if (hits.length) {
91
79
  const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
92
80
  const promptId = hookInput.prompt_id || 'none';
93
81
  if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
94
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('; ');
95
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.)';
96
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 {}
97
86
  if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
98
87
  st.mandateBlocks = (st.mandateBlocks || 0) + 1;
99
88
  saveState();
100
- 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}` }));
101
90
  process.exit(0);
102
91
  }
103
92
  saveState();
package/sawbuck.mjs CHANGED
@@ -210,9 +210,11 @@ switch (cmd) {
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
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. Two rules keep the baseline honest: a file still carrying
214
- // the content the last violation flagged is the agent's, not the person's, and is never
215
- // baselined; and the Stop hook ignores a baseline recorded well after the prompt began.
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.
216
218
  let hookEvent = '';
217
219
  if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
218
220
  if (hookEvent === 'UserPromptSubmit') {
@@ -220,10 +222,18 @@ switch (cmd) {
220
222
  if (mandatesInForce(repo).length) {
221
223
  const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
222
224
  let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
223
- const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
224
- const files = protectedDirty(repo);
225
- 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
226
- st.turnBaseline = { at: new Date().toISOString(), files };
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;
227
237
  mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
228
238
  }
229
239
  } catch { /* the frame never fails a prompt */ }