sawbuck 0.1.6 → 0.1.7
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/README.md +11 -5
- package/package.json +2 -2
- package/sawbuck.mjs +60 -14
package/README.md
CHANGED
|
@@ -7,13 +7,19 @@ A saddle for your coding harness. You write the contract (what a real win looks
|
|
|
7
7
|
> Sawbuck is the reference implementation of the [Verified-Outcome Contract](CONTRACT.md): the open interface for a coding loop you can trust.
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
10
|
+
# 1 · try it (no install, no commitment): verify your latest change right now
|
|
11
|
+
npx sawbuck check
|
|
12
|
+
|
|
13
|
+
# 2 · watch it live: attach once to this repo (a project-scoped Claude Code hook)
|
|
14
|
+
npm i -g sawbuck
|
|
15
|
+
sawbuck attach --claude
|
|
15
16
|
```
|
|
16
17
|
|
|
18
|
+
**Zero-config:** it auto-detects your test command, so there is nothing to write to start.
|
|
19
|
+
On attach it tells you exactly what it can measure here (and how to fix it if it can't).
|
|
20
|
+
`sawbuck init` is only for *customizing* what it measures (coverage, latency, a cost budget).
|
|
21
|
+
In CI instead of a local hook? use the GitHub Action. For every project at once: `sawbuck attach --user`.
|
|
22
|
+
|
|
17
23
|
On each change:
|
|
18
24
|
|
|
19
25
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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.6.0",
|
|
19
19
|
"@promptwheel/logbook": "^0.9.0"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
package/sawbuck.mjs
CHANGED
|
@@ -17,10 +17,26 @@ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSy
|
|
|
17
17
|
import { join, dirname, resolve } from 'node:path';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
20
21
|
|
|
21
22
|
const SELF = fileURLToPath(import.meta.url);
|
|
22
23
|
const HERE = dirname(SELF);
|
|
23
24
|
const HOOK = join(HERE, 'sawbuck-hook.mjs');
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
// resolve the promptwheel engine bin: env override, installed package, then the dev sibling
|
|
27
|
+
const PW = process.env.PW_BIN
|
|
28
|
+
|| ['promptwheel/bin/promptwheel.mjs', 'promptwheel'].reduce((r, s) => { try { return r || require.resolve(s); } catch { return r; } }, '')
|
|
29
|
+
|| join(HERE, '..', 'referee', 'bin', 'promptwheel.mjs');
|
|
30
|
+
// run the engine and parse its JSON verdict (it prints JSON even when it exits nonzero)
|
|
31
|
+
function gateReport(repo, a) {
|
|
32
|
+
let o = '', code = 0;
|
|
33
|
+
try { o = execFileSync(process.execPath, [PW, ...a, '--no-record', '--json'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 120000 }); }
|
|
34
|
+
catch (e) { o = (e.stdout || '').toString(); code = e.status || 0; }
|
|
35
|
+
let rep = null; try { rep = JSON.parse(o.slice(o.indexOf('{'))); } catch {}
|
|
36
|
+
return { rep, code };
|
|
37
|
+
}
|
|
38
|
+
// is the metric live (actually ran), or inert (never ran in isolation)?
|
|
39
|
+
const metricLive = (m) => !!m && !m.inert && m.status !== 'unmeasurable' && m.before != null;
|
|
24
40
|
const S_START = '# >>> sawbuck >>>';
|
|
25
41
|
const S_END = '# <<< sawbuck <<<';
|
|
26
42
|
const BLOCK_RE = new RegExp(`\\n?${S_START}[\\s\\S]*?${S_END}\\n?`, 'g');
|
|
@@ -64,8 +80,8 @@ Usage:
|
|
|
64
80
|
sawbuck statusline One-line segment for your status line (🐴 watching | N caught)
|
|
65
81
|
sawbuck status [repo] Show the results receipt (what the check has caught)
|
|
66
82
|
sawbuck log [repo] [-n N] Recent activity, newest first: every check (verified / gamed / regression)
|
|
67
|
-
sawbuck check [
|
|
68
|
-
|
|
83
|
+
sawbuck check [repo] Try it: verify your latest change right now (working tree if dirty, else the
|
|
84
|
+
last commit). Zero-config, no attach, no hook. The fastest way to see it work.
|
|
69
85
|
sawbuck rank [--base R --head R] [--file P ...]
|
|
70
86
|
Show the relevant past findings for the paths a change touches
|
|
71
87
|
sawbuck context [repo] Ranked relevant history for your current working changes (the read face)
|
|
@@ -95,9 +111,21 @@ switch (cmd) {
|
|
|
95
111
|
break;
|
|
96
112
|
}
|
|
97
113
|
case 'check': {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
// one-shot: gate your latest change right now (working tree if dirty, else the last commit),
|
|
115
|
+
// zero-config (the engine auto-detects your test command), and say plainly whether the win was
|
|
116
|
+
// earned. No attach, no hook, no record. The try-it-first on-ramp.
|
|
117
|
+
const repo = repoArg();
|
|
118
|
+
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
|
|
119
|
+
const dirty = g('status', '--porcelain').length > 0;
|
|
120
|
+
const where = dirty ? 'working' : 'last';
|
|
121
|
+
const { rep, code } = gateReport(repo, dirty ? ['run', '--working'] : ['run', '--base', 'HEAD~1', '--head', 'HEAD']);
|
|
122
|
+
if (!rep || !rep.verdict) { out('🐴 sawbuck: could not run the check here. Make sure this is a git repo with a commit, and a test command (or run `sawbuck init`).'); process.exit(2); }
|
|
123
|
+
const m = (rep.metrics || [])[0];
|
|
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
|
+
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
|
+
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.');
|
|
128
|
+
process.exit(code);
|
|
101
129
|
}
|
|
102
130
|
case 'log': {
|
|
103
131
|
// the event feed: a running log of what the check did, so you can watch it work.
|
|
@@ -148,9 +176,10 @@ switch (cmd) {
|
|
|
148
176
|
}
|
|
149
177
|
case 'attach': {
|
|
150
178
|
const repo = repoArg();
|
|
151
|
-
if (flags.has('--user')) { wireClaude(repo, { user: true }); break; }
|
|
152
|
-
if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); break; }
|
|
179
|
+
if (flags.has('--user')) { wireClaude(repo, { user: true }); if (!dry) verifyAttach(repo); break; }
|
|
180
|
+
if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); if (!dry) verifyAttach(repo); break; }
|
|
153
181
|
wireGit(repo);
|
|
182
|
+
if (!dry) verifyAttach(repo);
|
|
154
183
|
break;
|
|
155
184
|
}
|
|
156
185
|
case 'detach': {
|
|
@@ -174,9 +203,13 @@ switch (cmd) {
|
|
|
174
203
|
if (existsSync(join(repo, '.git'))) {
|
|
175
204
|
let caught = 0;
|
|
176
205
|
try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0); } catch {}
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
206
|
+
// the last verdict from the activity feed (includes verified passes), so you see it working each turn
|
|
207
|
+
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
|
|
210
|
+
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);
|
|
180
213
|
}
|
|
181
214
|
} catch {}
|
|
182
215
|
break;
|
|
@@ -184,6 +217,20 @@ switch (cmd) {
|
|
|
184
217
|
default: die(`unknown command '${cmd}'\n\n${help}`);
|
|
185
218
|
}
|
|
186
219
|
|
|
220
|
+
// after attach, prove the check can actually measure something here (the inert-metric trap):
|
|
221
|
+
// run the gate on the last commit and say what it can or cannot measure, so you never attach
|
|
222
|
+
// into silent nothing. Best-effort and bounded; the wire already succeeded regardless.
|
|
223
|
+
function verifyAttach(repo) {
|
|
224
|
+
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
|
|
225
|
+
if (!g('rev-parse', 'HEAD')) { out(' attached. Make a commit, then run `sawbuck check` to see it work.'); return; }
|
|
226
|
+
out(' verifying I can measure here (one-time)...');
|
|
227
|
+
const base = g('rev-parse', '--verify', 'HEAD~1') ? 'HEAD~1' : 'HEAD';
|
|
228
|
+
const { rep } = gateReport(repo, ['run', '--base', base, '--head', 'HEAD']);
|
|
229
|
+
const m = rep && (rep.metrics || [])[0];
|
|
230
|
+
if (metricLive(m)) out(` ✓ I can measure "${m.name}" here. On each commit I'll verify a win survived a source-only re-run, and flag it if it did not.`);
|
|
231
|
+
else out(' ⚠ I could not measure anything here yet (your test command did not run in isolation). Point me at it: `sawbuck init`, or set setup/linkDirs in promptwheel.config.json. I stay quiet until then.');
|
|
232
|
+
}
|
|
233
|
+
|
|
187
234
|
// --- the attach receipt (out of the worktree, inside .git) ------------------
|
|
188
235
|
function manifestPath(repo) { return join(repo, '.git', 'sawbuck', 'attach.json'); }
|
|
189
236
|
function userManifestPath() { return join(homedir(), '.sawbuck', 'user-attach.json'); }
|
|
@@ -458,8 +505,7 @@ function detachAll(purge) {
|
|
|
458
505
|
|
|
459
506
|
// --- shared helpers ---------------------------------------------------------
|
|
460
507
|
function dryText(plan) { return ['sawbuck --dry-run: would leave this footprint (writing nothing):', ...plan.map((p) => ' ' + p), ' all of it reversible with `detach`.'].join('\n'); }
|
|
461
|
-
function warnNoConfig(
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
508
|
+
function warnNoConfig() {
|
|
509
|
+
// superseded by verifyAttach, which runs after every attach and reports exactly what the check
|
|
510
|
+
// can measure (zero-config now auto-detects your test command, so a missing config is not an error).
|
|
465
511
|
}
|