sawbuck 0.1.7 โ†’ 0.1.9

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
@@ -81,6 +81,8 @@ A win is **`gamed`** when it does not survive a **source-only re-run**: revert e
81
81
 
82
82
  **Protected terms (declared, optionally enforced).** `evaluatorPaths` is also the declaration surface for immutability: the files a repo names there are its fixed terms, the goalposts the agent does not get to move. The baseline is detection (a win that depended on changing a protected term reads `gamed`). A stronger conformance level *enforces* it: the audit gate (CI, or a hook the agent cannot bypass) fails any change to a protected term unless a human signs off, so the terms cannot be moved mid-game. Note the honest limit: a file cannot be made truly immutable to an agent that has write access to the repo. The guarantee comes from the un-bypassable gate, not from the filesystem.
83
83
 
84
+ **Declared override (an intentional test change).** The check flags a win that came only from moving the goalposts, not from the source. Sometimes that move is legitimate: a test that asserted the wrong thing, an obsolete test removed, a guard intentionally retired. The sanctioned way through is to DECLARE it, not to silently weaken the gate. A `Sawbuck-Allow: <reason>` trailer on the commit (or `--allow "<reason>"` for a working-tree or CI run) acknowledges the change, so a would-be `gamed` reads `pass` with the acknowledgment recorded in `allowed` (the reason, and the metric or gate names it cleared). The check does not judge intent; it forces intent to be explicit and auditable. An UNdeclared test-only win is still `gamed`; a declared one is on the record, so a reviewer sees the reason instead of a silent green. It covers the edit, the delete, and the new file alike. The declaration lives in the commit, so it is immutable and attributable: revoking it is a review act, not a deletion, and a reasonless trailer does not override.
85
+
84
86
  ## 4. The context record (the trail knowledge)
85
87
 
86
88
  A conforming saddle also carries the repo's history as deterministic memory: a ledger derived from git (hotspots, reverted approaches, suppressed or weakened tests, fragile areas), surfaced per turn as a ranked slice for the files being touched. It is a projection of git, never a second source of truth, so it is always reconstructable and never drifts from what actually happened.
package/dispatch.mjs CHANGED
@@ -22,7 +22,7 @@ const L = rec.latest;
22
22
  // Attribute the latest catch to the agent that made it (the Claude hook passes 'claude').
23
23
  let head;
24
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`;
25
+ else if (L && L.kind === 'regression') head = L.by ? `๐Ÿด Sawbuck caught a regression from ${L.by}` : `๐Ÿด Sawbuck caught a regression`;
26
26
  else head = `๐Ÿด Sawbuck`;
27
27
  let line = head;
28
28
  if (L) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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.6.0",
18
+ "promptwheel": "^0.7.0",
19
19
  "@promptwheel/logbook": "^0.9.0"
20
20
  },
21
21
  "files": [
package/rank.mjs CHANGED
@@ -56,6 +56,10 @@ const recency = (d) => 1 / (1 + Math.max(0, (now - Date.parse(d)) / 86400000) /
56
56
  // weaken/suppress are commit-level signals; only credit them to code/test files,
57
57
  // not the README/config that merely rode along in the same commit.
58
58
  const codeish = (p) => /\.(m?[jt]sx?|cjs|py|go|rs|rb|java|kt|c|cc|cpp|h|hpp|php|swift|scala|clj|ex|exs)$/i.test(p);
59
+ // a suppression or assertion drop on a TEST path is a hidden-failure risk (a skipped
60
+ // or weakened test), so it outranks the same marker in app code (a lone style
61
+ // eslint-disable). The relevance lead: sharpen the signal, don't flatten it.
62
+ const isTest = (p) => /(^|\/)(tests?|__tests__|specs?)\//i.test(p) || /[._-](test|spec)\.[a-z0-9]+$/i.test(p) || /(^|\/)test_[^/]+\.py$/i.test(p);
59
63
 
60
64
  const DAY = 86400000;
61
65
  const ranked = paths.map((p) => {
@@ -67,7 +71,10 @@ const ranked = paths.map((p) => {
67
71
  if (e.revert) { w = 3; kind = 'revert (do-not-retry)'; }
68
72
  // "weakened" only on a NET assertion drop (logbook's own rule: del > add + 2),
69
73
  // a rewrite that removes some asserts but adds more is not a weakening.
70
- else if (codeish(p) && ((e.suppressions && e.suppressions.length) || ((e.del_asserts || 0) > (e.add_asserts || 0) + 2) || e.downgrades > 0)) { w = 2; kind = 'test weakened / suppressed'; }
74
+ else if (codeish(p) && ((e.suppressions && e.suppressions.length) || ((e.del_asserts || 0) > (e.add_asserts || 0) + 2) || e.downgrades > 0)) {
75
+ if (isTest(p)) { w = 2.5; kind = 'test disabled / weakened'; }
76
+ else { w = 2; kind = 'suppression in source'; }
77
+ }
71
78
  else if (e.fix) { w = 1.2; kind = 'bugfix here'; }
72
79
  else continue;
73
80
  const weighted = w * recency(e.date);
package/reflect.mjs CHANGED
@@ -107,7 +107,7 @@ process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
107
107
  function appendActivity(v, rep) {
108
108
  if (process.env.SAWBUCK_NO_LOG) return;
109
109
  const map = { pass: 'verified', gamed: 'gamed', fail: 'regression', inconclusive: 'inconclusive' };
110
- const k = map[v];
110
+ const k = (v === 'pass' && rep.allowed) ? 'allowed' : map[v]; // a declared override is logged distinctly, for the audit trail
111
111
  if (!k) return;
112
112
  let sha = '', file = '';
113
113
  try { sha = git('rev-parse', head).slice(0, 8); } catch {}
@@ -115,6 +115,7 @@ function appendActivity(v, rep) {
115
115
  const gm = (rep.metrics || []).find((m) => m.gamed === true);
116
116
  const why = k === 'gamed' ? (gm?.gamingReason || 'the win came from editing the goalposts')
117
117
  : k === 'regression' ? 'a guarded metric regressed beyond noise'
118
+ : k === 'allowed' ? ('declared intentional: ' + (rep.allowed?.reason || ''))
118
119
  : k === 'verified' ? 'the source earned the green'
119
120
  : 'could not certify a pass';
120
121
  const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160), by: by || null };
package/sawbuck.mjs CHANGED
@@ -124,7 +124,7 @@ switch (cmd) {
124
124
  if (rep.verdict === 'pass') out(`๐Ÿด โœ“ verified: your ${where} change earned it. ${m ? '"' + m.name + '"' : 'The metric'} held, and the source carried the win (it survives a source-only re-run).`);
125
125
  else if (rep.verdict === 'gamed') out('๐Ÿด ๐Ÿšฉ gamed: a win here came from editing the test, config, or grader, not the source. It does NOT survive a source-only re-run, so it does not count.');
126
126
  else if (rep.verdict === 'fail') out(`๐Ÿด โœ— regression: a guarded metric got worse (${(rep.metrics || []).filter((x) => x.guard && !x.ok).map((x) => x.name).join(', ') || 'a guard'}).`);
127
- else out('๐Ÿด โ‰ˆ couldn\'t measure: the check ran but your metric is inert (it did not actually run in isolation). Point it at your real test command: `sawbuck init`, or set setup/linkDirs in promptwheel.config.json.');
127
+ else out('๐Ÿด โ‰ˆ inconclusive: the check ran but could not measure your metric (it did not run in isolation). Point it at your real test command: `sawbuck init`, or set setup/linkDirs in promptwheel.config.json.');
128
128
  process.exit(code);
129
129
  }
130
130
  case 'log': {
@@ -135,7 +135,7 @@ switch (cmd) {
135
135
  if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
136
136
  const ni = rest.indexOf('-n');
137
137
  const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
138
- const icon = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ' };
138
+ const icon = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ', allowed: 'โœŽ' };
139
139
  const recent = lines.slice(-n).reverse();
140
140
  out(`sawbuck activity (last ${recent.length}):`);
141
141
  for (const l of recent) {
@@ -155,14 +155,19 @@ switch (cmd) {
155
155
  // the read face: ranked relevant history for what you're working on right now.
156
156
  const repo = repoArg();
157
157
  const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
158
+ // Silent when there is nothing to say, so it is safe on every UserPromptSubmit (that
159
+ // hook's stdout is injected into context; "nothing notable" every turn would be noise).
160
+ // --speak forces a reply, for an explicit `sawbuck context` / the /sawbuck-context command.
161
+ const speak = flags.has('--speak');
158
162
  let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
159
163
  if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
160
- if (!changed.length) break; // silent: safe as an every-turn hook
164
+ if (!changed.length) { if (speak) out('๐Ÿด sawbuck: nothing changed to look up (no working edits, and no previous commit to read).'); break; }
161
165
  const fileArgs = changed.flatMap((f) => ['--file', f]);
162
166
  let o = '';
163
167
  try { o = execFileSync(process.execPath, [join(HERE, 'rank.mjs'), '--repo', repo, ...fileArgs, '--top', '3'], { encoding: 'utf8' }); }
164
168
  catch (e) { o = (e.stdout || '').toString(); }
165
- if (/relevant history/.test(o)) out(o); // only speak when there is something
169
+ if (/relevant history/.test(o)) out(o);
170
+ else if (speak) out('๐Ÿด sawbuck: no notable history for the files you are touching (a clean trail).');
166
171
  break;
167
172
  }
168
173
  case 'run': {
@@ -205,11 +210,13 @@ switch (cmd) {
205
210
  try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0); } catch {}
206
211
  // the last verdict from the activity feed (includes verified passes), so you see it working each turn
207
212
  let last = '';
208
- try { const L = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').trim().split('\n'); const e = JSON.parse(L[L.length - 1]); last = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ' }[e.kind] || ''; } catch {}
209
- // profile readout: watching (something to check, incl. zero-config via a test command) vs memory-only
213
+ try { const L = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').trim().split('\n'); const e = JSON.parse(L[L.length - 1]); last = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive: 'โ‰ˆ', allowed: 'โœŽ' }[e.kind] || ''; } catch {}
214
+ // one state model, shared with the Reins band: watching (wired in) / ready (checkable, not wired yet) / memory (history only)
215
+ const active = existsSync(join(repo, '.sawbuck', 'state.json'));
210
216
  const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
211
- const seg = caught > 0 ? `๐Ÿด sawbuck ยท ${caught} caught` : armed ? `๐Ÿด sawbuck ยท watching` : `๐Ÿด sawbuck ยท memory`;
212
- process.stdout.write(last ? `${seg} ยท ${last}` : seg);
217
+ const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
218
+ const seg = caught > 0 ? `๐Ÿด sawbuck ยท ${caught} caught` : active ? `๐Ÿด sawbuck ยท watching` : armed ? `๐Ÿด sawbuck ยท ready` : mem ? `๐Ÿด sawbuck ยท memory` : '';
219
+ if (seg) process.stdout.write(last ? `${seg} ยท ${last}` : seg);
213
220
  }
214
221
  } catch {}
215
222
  break;