sawbuck 0.1.4 → 0.1.6

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.md CHANGED
@@ -46,10 +46,12 @@ The agent consumes this as a per-turn reward it cannot inflate. The harness cons
46
46
  "delta": 1,
47
47
  "status": "improved",
48
48
  "ok": true,
49
- "noise": { "band": 0, "runs": 3 },
49
+ "confidence": "high",
50
+ "noise": 0,
50
51
  "gamed": true,
52
+ "sourceOnly": null,
51
53
  "retained": 0.0,
52
- "gamingReason": "the win did not survive reverting the test changes"
54
+ "gamingReason": "the win changed zero production-source files, only test/config/grader/golden"
53
55
  }
54
56
  ]
55
57
  }
@@ -61,7 +63,9 @@ Fixed vocabularies (a conforming implementation uses exactly these):
61
63
  - `metric.status` ∈ `improved | regressed | unchanged | inconclusive | unmeasurable`
62
64
  - `direction` ∈ `up | down | pass` (the direction that counts as better; `pass` means a 0/1 gate)
63
65
 
64
- `retained` is the fraction of a metric's gain that survived the source-only re-run (§3). `noise` is the measured jitter band across `repeat` runs; a delta inside it is `inconclusive`, never a regression.
66
+ `retained` is the fraction of a metric's gain that survived the source-only re-run (§3). `noise` is the measured jitter band as a single number, across the top-level `repeat` runs; a delta inside it is `inconclusive`, never a regression. `confidence` (`unverified | low | medium | high`) and `sourceOnly` (the metric's value when only source edits are replayed, §3) are informational fields the reference implementation adds; a minimal conforming report may omit them. A consumer validates a report with `conformsToContract()` from the `promptwheel` package (see INTEGRATE.md) or `promptwheel conformance`.
67
+
68
+ `gate` (optional) is the gate-integrity record. `gate.fingerprint` is a truncated SHA-256 of the normalized gate definition at `base` and `head`, so a changed gate is visible in the audit record. `gate.weakened`, present only when it happened, lists guards the change dropped (removed, or set to `guard:false`) that would have failed at `head`. A non-empty `gate.weakened` forces `verdict: gamed`: an agent cannot earn a pass by disabling the guard it is being judged by.
65
69
 
66
70
  ## 3. The integrity boundary (what counts as gaming)
67
71
 
package/dispatch.mjs CHANGED
@@ -18,8 +18,17 @@ if (!rec || (!total && !force)) process.exit(0); // nothing recorded
18
18
  if (process.argv.includes('--json')) { console.log(JSON.stringify(rec)); process.exit(0); }
19
19
 
20
20
  const n = (x, s) => `${x} ${s}${x === 1 ? '' : 's'}`;
21
- const parts = ['Sawbuck', `${n(rec.regressions || 0, 'regression')} charted`];
22
- if (rec.gamed) parts.push(`${n(rec.gamed, 'gamed change')} caught`);
23
- let line = parts.join(' · ');
24
- if (rec.latest) line += `\n 🆕 just learned: ${rec.latest.reason || rec.latest.kind} (${rec.latest.file})`;
21
+ const L = rec.latest;
22
+ // Attribute the latest catch to the agent that made it (the Claude hook passes 'claude').
23
+ let head;
24
+ if (L && L.kind === 'gamed') head = L.by ? `🐴 Sawbuck caught ${L.by} gaming a metric` : `🐴 Sawbuck caught a gamed change`;
25
+ else if (L && L.kind === 'regression') head = L.by ? `🐴 Sawbuck flagged a regression from ${L.by}` : `🐴 Sawbuck flagged a regression`;
26
+ else head = `🐴 Sawbuck`;
27
+ let line = head;
28
+ if (L) {
29
+ const why = String(L.reason || L.kind).replace(/^a change here was gamed,\s*/i, '').replace(/^a change here\s+/i, '');
30
+ line += `\n ${L.file}: ${why}`;
31
+ }
32
+ const tally = [rec.gamed ? n(rec.gamed, 'gamed change') : '', rec.regressions ? n(rec.regressions, 'regression') : ''].filter(Boolean).join(', ');
33
+ if (tally) line += `\n (${tally} caught here so far)`;
25
34
  console.log(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Sign your agent to a contract so it does what you want: a deterministic, zero-LLM check that re-proves every win from the agent's source edits alone, so a green that came from editing the test is caught and reverted. A saddle for your coding harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@
15
15
  "sawbuck": "sawbuck.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "promptwheel": "^0.4.5",
18
+ "promptwheel": "^0.5.0",
19
19
  "@promptwheel/logbook": "^0.9.0"
20
20
  },
21
21
  "files": [
package/reflect.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // reflect.mjs, the Sawbuck feedback edge (M0).
3
3
  //
4
- // Run the check (packages/referee) on the latest commit; on an gamed or
4
+ // Run the check (the promptwheel engine) on the latest commit; on a gamed or
5
5
  // regression result, deterministic ground truth, record a finding in the
6
- // memory (packages/logbook) on the touched source paths, and tick the results
6
+ // memory (the logbook engine) on the touched source paths, and tick the results
7
7
  // record. Quiet by default; the dispatch surfaces catches on a cadence.
8
8
  //
9
9
  // Zero-dep, Node ESM. Orchestrates the engine CLIs behind a clean layer
@@ -39,6 +39,7 @@ const flag = (name, def) => { const i = process.argv.indexOf(name); return i > -
39
39
  const repo = flag('--repo', process.cwd());
40
40
  const base = flag('--base', 'HEAD~1');
41
41
  const head = flag('--head', 'HEAD');
42
+ const by = flag('--by', ''); // the agent that authored the change (Claude-first: the Claude hook passes 'claude'); empty = unattributed (raw-git path)
42
43
 
43
44
  const git = (...a) => execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim();
44
45
  // backstop so the check can never hang a commit/turn; generous default, overridable.
@@ -94,7 +95,7 @@ const recPath = join(recDir, 'results.json');
94
95
  let rec = { regressions: 0, gamed: 0, latest: null };
95
96
  try { if (existsSync(recPath)) rec = JSON.parse(readFileSync(recPath, 'utf8')); } catch {}
96
97
  if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1;
97
- rec.latest = { sha: sha.slice(0, 8), kind, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString() };
98
+ rec.latest = { sha: sha.slice(0, 8), kind, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString(), by: by || null };
98
99
  try { mkdirSync(recDir, { recursive: true }); writeFileSync(recPath, JSON.stringify(rec, null, 2) + '\n'); } catch {}
99
100
 
100
101
  // --- 5) quiet by default (dispatch surfaces on cadence); one line to stderr -
@@ -116,7 +117,7 @@ function appendActivity(v, rep) {
116
117
  : k === 'regression' ? 'a guarded metric regressed beyond noise'
117
118
  : k === 'verified' ? 'the source earned the green'
118
119
  : 'could not certify a pass';
119
- const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160) };
120
+ const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160), by: by || null };
120
121
  const dir = join(repo, '.sawbuck');
121
122
  const p = join(dir, 'activity.jsonl');
122
123
  try {
package/sawbuck-hook.mjs CHANGED
@@ -12,6 +12,10 @@ import { fileURLToPath } from 'node:url';
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
13
  const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
14
14
  const repo = flag('--repo', process.cwd());
15
+ const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
16
+ const stop = process.argv.includes('--stop'); // wired as the Claude Code Stop hook: enforce the contract on a gamed turn
17
+ let hookInput = {};
18
+ if (stop) { try { hookInput = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch {} } // the Stop hook's JSON on stdin (stop_hook_active, prompt_id, ...)
15
19
  ensureExcluded(repo); // keep the record out of git status under ANY attach mode (incl. --user)
16
20
 
17
21
  const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
@@ -31,14 +35,41 @@ try { if (existsSync(stPath)) st = JSON.parse(readFileSync(stPath, 'utf8')); } c
31
35
  // 1) new commit(s) since last turn → run the check on them
32
36
  const head = git('rev-parse', 'HEAD');
33
37
  if (head && st.lastSha && head !== st.lastSha) {
34
- node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', st.lastSha, '--head', head]);
38
+ node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', st.lastSha, '--head', head, ...(by ? ['--by', by] : [])]);
35
39
  }
36
40
  st.lastSha = head || st.lastSha;
37
41
  st.turns += 1;
38
42
 
39
- // 2) decide whether to speak, fresh catch always; cadence unless onlyOnCatch
40
- let total = 0;
41
- try { const r = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); total = (r.regressions || 0) + (r.gamed || 0); } catch {}
43
+ // 2) read the record of what the check has charted
44
+ let rec = null;
45
+ try { rec = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch {}
46
+ const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
47
+ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
48
+
49
+ // 3) CONTRACT ENFORCEMENT (Claude Code Stop hook only): if THIS turn's commit was gamed,
50
+ // block the stop and send the agent back to earn the green in the source. Bounded per
51
+ // prompt, because Claude Code's built-in block cap resets on every tool call and so cannot
52
+ // bound a tool-using agent on its own. Opt out with SAWBUCK_NO_ENFORCE=1 (falls back to a flag).
53
+ const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
54
+ if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
55
+ const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
56
+ const promptId = hookInput.prompt_id || 'none';
57
+ if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
58
+ st.shownTotal = total;
59
+ const where = rec.latest.file;
60
+ if ((st.blockCount || 0) < MAX_BLOCKS) {
61
+ st.blockCount = (st.blockCount || 0) + 1;
62
+ saveState();
63
+ const reason = `Sawbuck: the win on ${where} was gamed. It came from editing the test or config, not the source, so it did not survive a source-only re-run. The turn is not done: fix the source so the metric passes on its own, then finish.`;
64
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
65
+ process.exit(0);
66
+ }
67
+ saveState(); // budget spent: let the turn end, but warn loudly so it never loops forever
68
+ process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: the win on ${where} is still gamed and needs a real source fix.` }));
69
+ process.exit(0);
70
+ }
71
+
72
+ // 4) otherwise, surface a catch on a fresh catch or on cadence (a flag; never blocks)
42
73
  const freshCatch = total > (st.shownTotal || 0);
43
74
  const cadenceHit = everyTurns > 0 && st.turns % everyTurns === 0;
44
75
  if (freshCatch || (cadenceHit && !onlyOnCatch)) {
@@ -46,8 +77,7 @@ if (freshCatch || (cadenceHit && !onlyOnCatch)) {
46
77
  if (out.trim()) process.stdout.write(out.endsWith('\n') ? out : out + '\n');
47
78
  st.shownTotal = total;
48
79
  }
49
-
50
- try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {}
80
+ saveState();
51
81
 
52
82
  // Keep the record dirs out of `git status` via .git/info/exclude, so Sawbuck never
53
83
  // dirties the worktree, including under `attach --user`, which is global and cannot
package/sawbuck.mjs CHANGED
@@ -291,7 +291,7 @@ function wireClaude(repo, opts) {
291
291
  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.`); } }
292
292
  if (JSON.stringify(cfg).includes('sawbuck.mjs')) { out(`sawbuck: already attached (${user ? 'user' : 'claude'}) in ${f}`); if (!user) warnNoConfig(repo); return; }
293
293
  const ctx = user ? `node ${JSON.stringify(SELF)} context` : `node ${JSON.stringify(SELF)} context ${JSON.stringify(repo)}`;
294
- const chk = user ? `node ${JSON.stringify(SELF)} __hook` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)}`;
294
+ const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
295
295
  if (dry) {
296
296
  out(dryText([
297
297
  `${fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + Stop hooks)`,