sawbuck 0.1.4 → 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/CONTRACT.md CHANGED
@@ -46,10 +46,12 @@ The agent consumes this as a per-turn reward it cannot inflate. The harness cons
46
46
  "delta": 1,
47
47
  "status": "improved",
48
48
  "ok": true,
49
- "noise": { "band": 0, "runs": 3 },
49
+ "confidence": "high",
50
+ "noise": 0,
50
51
  "gamed": true,
52
+ "sourceOnly": null,
51
53
  "retained": 0.0,
52
- "gamingReason": "the win did not survive reverting the test changes"
54
+ "gamingReason": "the win changed zero production-source files, only test/config/grader/golden"
53
55
  }
54
56
  ]
55
57
  }
@@ -61,7 +63,9 @@ Fixed vocabularies (a conforming implementation uses exactly these):
61
63
  - `metric.status` ∈ `improved | regressed | unchanged | inconclusive | unmeasurable`
62
64
  - `direction` ∈ `up | down | pass` (the direction that counts as better; `pass` means a 0/1 gate)
63
65
 
64
- `retained` is the fraction of a metric's gain that survived the source-only re-run (§3). `noise` is the measured jitter band across `repeat` runs; a delta inside it is `inconclusive`, never a regression.
66
+ `retained` is the fraction of a metric's gain that survived the source-only re-run (§3). `noise` is the measured jitter band as a single number, across the top-level `repeat` runs; a delta inside it is `inconclusive`, never a regression. `confidence` (`unverified | low | medium | high`) and `sourceOnly` (the metric's value when only source edits are replayed, §3) are informational fields the reference implementation adds; a minimal conforming report may omit them. A consumer validates a report with `conformsToContract()` from the `promptwheel` package (see INTEGRATE.md) or `promptwheel conformance`.
67
+
68
+ `gate` (optional) is the gate-integrity record. `gate.fingerprint` is a truncated SHA-256 of the normalized gate definition at `base` and `head`, so a changed gate is visible in the audit record. `gate.weakened`, present only when it happened, lists guards the change dropped (removed, or set to `guard:false`) that would have failed at `head`. A non-empty `gate.weakened` forces `verdict: gamed`: an agent cannot earn a pass by disabling the guard it is being judged by.
65
69
 
66
70
  ## 3. The integrity boundary (what counts as gaming)
67
71
 
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
@@ -18,8 +18,17 @@ if (!rec || (!total && !force)) process.exit(0); // nothing recorded
18
18
  if (process.argv.includes('--json')) { console.log(JSON.stringify(rec)); process.exit(0); }
19
19
 
20
20
  const n = (x, s) => `${x} ${s}${x === 1 ? '' : 's'}`;
21
- const parts = ['Sawbuck', `${n(rec.regressions || 0, 'regression')} charted`];
22
- if (rec.gamed) parts.push(`${n(rec.gamed, 'gamed change')} caught`);
23
- let line = parts.join(' · ');
24
- if (rec.latest) line += `\n 🆕 just learned: ${rec.latest.reason || rec.latest.kind} (${rec.latest.file})`;
21
+ const L = rec.latest;
22
+ // Attribute the latest catch to the agent that made it (the Claude hook passes 'claude').
23
+ let head;
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`;
26
+ else head = `🐴 Sawbuck`;
27
+ let line = head;
28
+ if (L) {
29
+ const why = String(L.reason || L.kind).replace(/^a change here was gamed,\s*/i, '').replace(/^a change here\s+/i, '');
30
+ line += `\n ${L.file}: ${why}`;
31
+ }
32
+ const tally = [rec.gamed ? n(rec.gamed, 'gamed change') : '', rec.regressions ? n(rec.regressions, 'regression') : ''].filter(Boolean).join(', ');
33
+ if (tally) line += `\n (${tally} caught here so far)`;
25
34
  console.log(line);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.4",
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.4.5",
18
+ "promptwheel": "^0.6.0",
19
19
  "@promptwheel/logbook": "^0.9.0"
20
20
  },
21
21
  "files": [
package/reflect.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // reflect.mjs, the Sawbuck feedback edge (M0).
3
3
  //
4
- // Run the check (packages/referee) on the latest commit; on an gamed or
4
+ // Run the check (the promptwheel engine) on the latest commit; on a gamed or
5
5
  // regression result, deterministic ground truth, record a finding in the
6
- // memory (packages/logbook) on the touched source paths, and tick the results
6
+ // memory (the logbook engine) on the touched source paths, and tick the results
7
7
  // record. Quiet by default; the dispatch surfaces catches on a cadence.
8
8
  //
9
9
  // Zero-dep, Node ESM. Orchestrates the engine CLIs behind a clean layer
@@ -39,6 +39,7 @@ const flag = (name, def) => { const i = process.argv.indexOf(name); return i > -
39
39
  const repo = flag('--repo', process.cwd());
40
40
  const base = flag('--base', 'HEAD~1');
41
41
  const head = flag('--head', 'HEAD');
42
+ const by = flag('--by', ''); // the agent that authored the change (Claude-first: the Claude hook passes 'claude'); empty = unattributed (raw-git path)
42
43
 
43
44
  const git = (...a) => execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim();
44
45
  // backstop so the check can never hang a commit/turn; generous default, overridable.
@@ -94,7 +95,7 @@ const recPath = join(recDir, 'results.json');
94
95
  let rec = { regressions: 0, gamed: 0, latest: null };
95
96
  try { if (existsSync(recPath)) rec = JSON.parse(readFileSync(recPath, 'utf8')); } catch {}
96
97
  if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1;
97
- rec.latest = { sha: sha.slice(0, 8), kind, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString() };
98
+ rec.latest = { sha: sha.slice(0, 8), kind, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString(), by: by || null };
98
99
  try { mkdirSync(recDir, { recursive: true }); writeFileSync(recPath, JSON.stringify(rec, null, 2) + '\n'); } catch {}
99
100
 
100
101
  // --- 5) quiet by default (dispatch surfaces on cadence); one line to stderr -
@@ -116,7 +117,7 @@ function appendActivity(v, rep) {
116
117
  : k === 'regression' ? 'a guarded metric regressed beyond noise'
117
118
  : k === 'verified' ? 'the source earned the green'
118
119
  : 'could not certify a pass';
119
- const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160) };
120
+ const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160), by: by || null };
120
121
  const dir = join(repo, '.sawbuck');
121
122
  const p = join(dir, 'activity.jsonl');
122
123
  try {
package/sawbuck-hook.mjs CHANGED
@@ -12,6 +12,10 @@ import { fileURLToPath } from 'node:url';
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
13
  const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
14
14
  const repo = flag('--repo', process.cwd());
15
+ const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
16
+ const stop = process.argv.includes('--stop'); // wired as the Claude Code Stop hook: enforce the contract on a gamed turn
17
+ let hookInput = {};
18
+ if (stop) { try { hookInput = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch {} } // the Stop hook's JSON on stdin (stop_hook_active, prompt_id, ...)
15
19
  ensureExcluded(repo); // keep the record out of git status under ANY attach mode (incl. --user)
16
20
 
17
21
  const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
@@ -31,14 +35,41 @@ try { if (existsSync(stPath)) st = JSON.parse(readFileSync(stPath, 'utf8')); } c
31
35
  // 1) new commit(s) since last turn → run the check on them
32
36
  const head = git('rev-parse', 'HEAD');
33
37
  if (head && st.lastSha && head !== st.lastSha) {
34
- node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', st.lastSha, '--head', head]);
38
+ node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', st.lastSha, '--head', head, ...(by ? ['--by', by] : [])]);
35
39
  }
36
40
  st.lastSha = head || st.lastSha;
37
41
  st.turns += 1;
38
42
 
39
- // 2) decide whether to speak, fresh catch always; cadence unless onlyOnCatch
40
- let total = 0;
41
- try { const r = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); total = (r.regressions || 0) + (r.gamed || 0); } catch {}
43
+ // 2) read the record of what the check has charted
44
+ let rec = null;
45
+ try { rec = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch {}
46
+ const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
47
+ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
48
+
49
+ // 3) CONTRACT ENFORCEMENT (Claude Code Stop hook only): if THIS turn's commit was gamed,
50
+ // block the stop and send the agent back to earn the green in the source. Bounded per
51
+ // prompt, because Claude Code's built-in block cap resets on every tool call and so cannot
52
+ // bound a tool-using agent on its own. Opt out with SAWBUCK_NO_ENFORCE=1 (falls back to a flag).
53
+ const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
54
+ if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
55
+ const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
56
+ const promptId = hookInput.prompt_id || 'none';
57
+ if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
58
+ st.shownTotal = total;
59
+ const where = rec.latest.file;
60
+ if ((st.blockCount || 0) < MAX_BLOCKS) {
61
+ st.blockCount = (st.blockCount || 0) + 1;
62
+ saveState();
63
+ const reason = `Sawbuck: the win on ${where} was gamed. It came from editing the test or config, not the source, so it did not survive a source-only re-run. The turn is not done: fix the source so the metric passes on its own, then finish.`;
64
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }));
65
+ process.exit(0);
66
+ }
67
+ saveState(); // budget spent: let the turn end, but warn loudly so it never loops forever
68
+ process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck stopped blocking after ${MAX_BLOCKS} tries: the win on ${where} is still gamed and needs a real source fix.` }));
69
+ process.exit(0);
70
+ }
71
+
72
+ // 4) otherwise, surface a catch on a fresh catch or on cadence (a flag; never blocks)
42
73
  const freshCatch = total > (st.shownTotal || 0);
43
74
  const cadenceHit = everyTurns > 0 && st.turns % everyTurns === 0;
44
75
  if (freshCatch || (cadenceHit && !onlyOnCatch)) {
@@ -46,8 +77,7 @@ if (freshCatch || (cadenceHit && !onlyOnCatch)) {
46
77
  if (out.trim()) process.stdout.write(out.endsWith('\n') ? out : out + '\n');
47
78
  st.shownTotal = total;
48
79
  }
49
-
50
- try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {}
80
+ saveState();
51
81
 
52
82
  // Keep the record dirs out of `git status` via .git/info/exclude, so Sawbuck never
53
83
  // dirties the worktree, including under `attach --user`, which is global and cannot
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'); }
@@ -291,7 +338,7 @@ function wireClaude(repo, opts) {
291
338
  if (fileExisted) { try { cfg = JSON.parse(readFileSync(f, 'utf8')); } catch { die(`${f} exists but is not valid JSON; I will not overwrite it. Add the hooks by hand.`); } }
292
339
  if (JSON.stringify(cfg).includes('sawbuck.mjs')) { out(`sawbuck: already attached (${user ? 'user' : 'claude'}) in ${f}`); if (!user) warnNoConfig(repo); return; }
293
340
  const ctx = user ? `node ${JSON.stringify(SELF)} context` : `node ${JSON.stringify(SELF)} context ${JSON.stringify(repo)}`;
294
- const chk = user ? `node ${JSON.stringify(SELF)} __hook` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)}`;
341
+ const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
295
342
  if (dry) {
296
343
  out(dryText([
297
344
  `${fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + Stop hooks)`,
@@ -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
  }