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 +2 -0
- package/dispatch.mjs +1 -1
- package/package.json +2 -2
- package/rank.mjs +8 -1
- package/reflect.mjs +2 -1
- package/sawbuck.mjs +15 -8
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
|
|
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.
|
|
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.
|
|
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)) {
|
|
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('๐ด โ
|
|
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)
|
|
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);
|
|
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
|
-
//
|
|
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
|
|
212
|
-
|
|
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;
|