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.
Files changed (3) hide show
  1. package/README.md +11 -5
  2. package/package.json +2 -2
  3. 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
- 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.6",
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.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('🐴 ≈ 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
- // 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
+ // 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(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
- }
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
  }