sawbuck 0.1.6 → 0.1.8

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 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
- npm i -g sawbuck # also pulls the promptwheel + @promptwheel/logbook engines
11
- cd your-repo
12
- sawbuck attach # wire it into the repo (a git post-commit hook)
13
- # or, once, for every project in Claude Code:
14
- sawbuck attach --user # user-level hooks that profile-switch by cwd
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/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.6",
3
+ "version": "0.1.8",
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.5.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 [--base R --head R]
68
- Run the check on a commit and record the outcome
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
- try { execFileSync(process.execPath, [join(HERE, 'reflect.mjs'), ...rest], { stdio: 'inherit' }); }
99
- catch (e) { process.exit(e.status || 1); }
100
- break;
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('🐴 ≈ 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
+ 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,15 @@ 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
- // profile readout: armed (a contract to check) vs memory-only (history live, no contract yet)
178
- const armed = existsSync(join(repo, 'promptwheel.config.json'));
179
- process.stdout.write(caught > 0 ? `🐴 sawbuck · ${caught} caught` : armed ? `🐴 sawbuck · watching` : `🐴 sawbuck · memory`);
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
+ // one state model, shared with the Reins band: watching (wired in) / ready (checkable, not wired yet) / memory (history only)
210
+ const active = existsSync(join(repo, '.sawbuck', 'state.json'));
211
+ const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
212
+ const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
213
+ const seg = caught > 0 ? `🐴 sawbuck · ${caught} caught` : active ? `🐴 sawbuck · watching` : armed ? `🐴 sawbuck · ready` : mem ? `🐴 sawbuck · memory` : '';
214
+ if (seg) process.stdout.write(last ? `${seg} · ${last}` : seg);
180
215
  }
181
216
  } catch {}
182
217
  break;
@@ -184,6 +219,20 @@ switch (cmd) {
184
219
  default: die(`unknown command '${cmd}'\n\n${help}`);
185
220
  }
186
221
 
222
+ // after attach, prove the check can actually measure something here (the inert-metric trap):
223
+ // run the gate on the last commit and say what it can or cannot measure, so you never attach
224
+ // into silent nothing. Best-effort and bounded; the wire already succeeded regardless.
225
+ function verifyAttach(repo) {
226
+ const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
227
+ if (!g('rev-parse', 'HEAD')) { out(' attached. Make a commit, then run `sawbuck check` to see it work.'); return; }
228
+ out(' verifying I can measure here (one-time)...');
229
+ const base = g('rev-parse', '--verify', 'HEAD~1') ? 'HEAD~1' : 'HEAD';
230
+ const { rep } = gateReport(repo, ['run', '--base', base, '--head', 'HEAD']);
231
+ const m = rep && (rep.metrics || [])[0];
232
+ 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.`);
233
+ 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.');
234
+ }
235
+
187
236
  // --- the attach receipt (out of the worktree, inside .git) ------------------
188
237
  function manifestPath(repo) { return join(repo, '.git', 'sawbuck', 'attach.json'); }
189
238
  function userManifestPath() { return join(homedir(), '.sawbuck', 'user-attach.json'); }
@@ -458,8 +507,7 @@ function detachAll(purge) {
458
507
 
459
508
  // --- shared helpers ---------------------------------------------------------
460
509
  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(repo) {
462
- if (!existsSync(join(repo, 'promptwheel.config.json'))) {
463
- out(`note: no promptwheel.config.json in ${repo}, the check needs one to know what to measure. Run: sawbuck init`);
464
- }
510
+ function warnNoConfig() {
511
+ // superseded by verifyAttach, which runs after every attach and reports exactly what the check
512
+ // can measure (zero-config now auto-detects your test command, so a missing config is not an error).
465
513
  }