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 +7 -3
- package/README.md +11 -5
- package/dispatch.mjs +13 -4
- package/package.json +2 -2
- package/reflect.mjs +5 -4
- package/sawbuck-hook.mjs +36 -6
- package/sawbuck.mjs +61 -15
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
|
-
"
|
|
49
|
+
"confidence": "high",
|
|
50
|
+
"noise": 0,
|
|
50
51
|
"gamed": true,
|
|
52
|
+
"sourceOnly": null,
|
|
51
53
|
"retained": 0.0,
|
|
52
|
-
"gamingReason": "the win
|
|
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
|
-
|
|
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/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
|
|
22
|
-
|
|
23
|
-
let
|
|
24
|
-
if (
|
|
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.
|
|
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/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 (
|
|
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 (
|
|
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)
|
|
40
|
-
let
|
|
41
|
-
try {
|
|
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 [
|
|
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'); }
|
|
@@ -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(
|
|
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
|
}
|