sawbuck 0.1.28 → 0.1.29

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
@@ -92,7 +92,12 @@ export function mandateHits(repo, changedPaths, opts = {}) {
92
92
  const files = changedPaths.filter((f) => res.some((re) => re.test(f)));
93
93
  if (!files.length) continue;
94
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; }
95
+ if (prev) {
96
+ prev.files = [...new Set([...prev.files, ...files])].slice(0, 20);
97
+ if (!prev.reason && m.reason) prev.reason = String(m.reason).slice(0, 300);
98
+ if (m.committed) prev.committed = true; // the committed version of the rule hit too: say so
99
+ continue;
100
+ }
96
101
  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
102
  }
98
103
  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.29",
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
@@ -46,6 +46,23 @@ 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
+ }
49
66
 
50
67
  // 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
51
68
  // tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
@@ -56,11 +73,20 @@ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFile
56
73
  // baseline `sawbuck turn` recorded) is the person's own work and does not count.
57
74
  if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
58
75
  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];
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 = {};
82
+ const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
83
+ const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
61
84
  const changed = dirty.filter((f) => !untouched(f));
62
85
  const leftAlone = dirty.filter(untouched);
63
86
  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(); }
64
90
  if (hits.length) {
65
91
  const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
66
92
  const promptId = hookInput.prompt_id || 'none';
package/sawbuck.mjs CHANGED
@@ -209,14 +209,21 @@ 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. 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.
212
216
  let hookEvent = '';
213
- if (!process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
217
+ if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
214
218
  if (hookEvent === 'UserPromptSubmit') {
215
219
  try {
216
220
  if (mandatesInForce(repo).length) {
217
221
  const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
218
222
  let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
219
- st.turnBaseline = { at: new Date().toISOString(), files: protectedDirty(repo) };
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 };
220
227
  mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
221
228
  }
222
229
  } catch { /* the frame never fails a prompt */ }
@@ -459,7 +466,7 @@ function wireClaude(repo, opts) {
459
466
  const fileExisted = existsSync(f);
460
467
  let cfg = {};
461
468
  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)}`;
469
+ const ctx = user ? `node ${JSON.stringify(SELF)} turn --hook` : `node ${JSON.stringify(SELF)} turn --hook ${JSON.stringify(repo)}`;
463
470
  const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
464
471
  // attached already? "already attached" only when every hook of THIS version is present; an older
465
472
  // attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.