sawbuck 0.1.29 → 0.1.31
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 +2 -2
- package/contract.mjs +6 -0
- package/debt.mjs +5 -1
- package/package.json +2 -2
- package/reflect.mjs +23 -10
- package/sawbuck-hook.mjs +50 -42
- package/sawbuck.mjs +49 -20
- package/status.mjs +11 -0
package/CONTRACT.md
CHANGED
|
@@ -25,7 +25,7 @@ The per-turn signal a loop consumes. The check (`run`) exits:
|
|
|
25
25
|
| `0` | `pass` | The win is real: it survived a source-only re-proof and no guard regressed. | Keep the change. |
|
|
26
26
|
| `1` | `fail` | A guarded metric regressed beyond the measured noise band. | Revert. |
|
|
27
27
|
| `2` | `gamed` | A metric improved only by editing the goalposts (test, grader, golden, config), not the source. | Revert and flag. |
|
|
28
|
-
| `3` | `inconclusive` | Nothing could be certified: a guard measured
|
|
28
|
+
| `3` | `inconclusive` | Nothing could be certified: a guard never ran (inert) and no metric was measured to improve, or a win's source-only re-run could not be applied, so the win could not be re-proved either way. | Do not trust the green; do not keep on this signal alone. |
|
|
29
29
|
|
|
30
30
|
The agent consumes this as a per-turn reward it cannot inflate. The harness consumes the same exit as a merge gate the agent cannot self-clear.
|
|
31
31
|
|
|
@@ -67,7 +67,7 @@ Fixed vocabularies (a conforming implementation uses exactly these):
|
|
|
67
67
|
- `metric.status` ∈ `improved | regressed | unchanged | inconclusive | unmeasurable`
|
|
68
68
|
- `direction` ∈ `up | down | pass` (the direction that counts as better; `pass` means a 0/1 gate)
|
|
69
69
|
|
|
70
|
-
`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
|
|
70
|
+
`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 leaves that metric `inconclusive` (neither a win nor a regression; the verdict is not failed by it). `confidence` (`none | unverified | low | medium | high`, `none` for an unmeasurable metric) 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`.
|
|
71
71
|
|
|
72
72
|
`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.
|
|
73
73
|
|
package/contract.mjs
CHANGED
|
@@ -53,6 +53,12 @@ export function mandatesInForce(repo, ref = 'HEAD') {
|
|
|
53
53
|
return [...wt, ...committed];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// the paths among `paths` that a mandate in force covers (any rule, working tree or committed)
|
|
57
|
+
export function protectedAmong(repo, paths) {
|
|
58
|
+
const res = mandatesInForce(repo).flatMap((m) => pathsOf(m).map(globToRe));
|
|
59
|
+
return paths.filter((f) => res.some((re) => re.test(f)));
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
// a working-tree file's content hash as git would store it; 'deleted' when it is gone
|
|
57
63
|
export function hashOf(repo, file) {
|
|
58
64
|
try { return gitOut(repo, ['hash-object', '--', file]).trim(); } catch { return 'deleted'; }
|
package/debt.mjs
CHANGED
|
@@ -33,7 +33,11 @@ const bare = live.filter((it) => !it.documented && it.content).length;
|
|
|
33
33
|
if (since && typeof since === 'string') {
|
|
34
34
|
const norm = (s) => String(s).trim().replace(/\s+/g, ' ');
|
|
35
35
|
// the delta base: the raw suppression set at REF (the adapter's ref mode), subtracted from HEAD.
|
|
36
|
-
|
|
36
|
+
// null = the ref could not be read (not fetched, a typo): unmeasurable is NOT an empty base, or
|
|
37
|
+
// every pre-existing suppression would read as newly added and a --fail-on gate would fail falsely.
|
|
38
|
+
const baseRows = memDebt(repo, { ref: since });
|
|
39
|
+
if (baseRows === null) { console.error(`sawbuck debt: could not read the suppression ledger at ${since} (is the ref fetched? in GitHub Actions use actions/checkout with fetch-depth: 0)`); process.exit(1); }
|
|
40
|
+
const base = new Set(baseRows.map((r) => r.file + '\u0000' + norm(r.content)));
|
|
37
41
|
const added = live.filter((it) => it.content && !base.has(it.file + '\u0000' + norm(it.content)));
|
|
38
42
|
const skips = added.filter((it) => it.testSkip).length, undoc = added.filter((it) => !it.documented).length;
|
|
39
43
|
// the gate: fail (exit 1) when the branch adds the kind of debt --fail-on names.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves every win from the agent's source edits alone, so a green that came from editing the test, grader, or config is caught and sent back. Works in Claude Code, Codex, git hooks, and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@promptwheel/logbook": "^0.9.6",
|
|
37
|
-
"promptwheel": "^0.7.
|
|
37
|
+
"promptwheel": "^0.7.9"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"sawbuck.mjs",
|
package/reflect.mjs
CHANGED
|
@@ -30,6 +30,7 @@ const repo = flag('--repo', process.cwd());
|
|
|
30
30
|
const base = flag('--base', 'HEAD~1');
|
|
31
31
|
const head = flag('--head', 'HEAD');
|
|
32
32
|
const by = flag('--by', ''); // the agent that authored the change (Claude-first: the Claude hook passes 'claude'); empty = unattributed (raw-git path)
|
|
33
|
+
const noAuto = process.argv.includes('--no-auto'); // user-level wiring: measure only where a contract exists, never auto-gate a stranger's suite
|
|
33
34
|
|
|
34
35
|
const git = (...a) => execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim();
|
|
35
36
|
// backstop so the check can never hang a commit/turn; generous default, overridable.
|
|
@@ -42,7 +43,7 @@ const runNode = (file, a) => execFileSync(process.execPath, [file, ...a], { cwd:
|
|
|
42
43
|
function check() {
|
|
43
44
|
const parse = (out) => { const i = out.indexOf('{'); return i < 0 ? null : JSON.parse(out.slice(i)); };
|
|
44
45
|
try {
|
|
45
|
-
return parse(runNode(PW, ['run', '--base', base, '--head', head, '--json']));
|
|
46
|
+
return parse(runNode(PW, ['run', '--base', base, '--head', head, '--json', ...(noAuto ? ['--no-auto'] : [])]));
|
|
46
47
|
} catch (e) {
|
|
47
48
|
return parse((e.stdout || '').toString());
|
|
48
49
|
}
|
|
@@ -52,6 +53,20 @@ const report = check();
|
|
|
52
53
|
if (!report || !report.verdict) process.exit(0); // couldn't measure → nothing to learn
|
|
53
54
|
const verdict = report.verdict; // engine contract: 'gamed' | 'fail' | ...
|
|
54
55
|
appendActivity(verdict, report); // the event feed logs EVERY measurable check, so you can watch it work (verified too, not only catches)
|
|
56
|
+
// the results record, and the base this run measured from (a full sha), so a gamed verdict can be
|
|
57
|
+
// held against the SAME span until it re-proves: the hook re-runs base..HEAD, not last-commit..HEAD
|
|
58
|
+
const recDir = join(repo, '.sawbuck');
|
|
59
|
+
const recPath = join(recDir, 'results.json');
|
|
60
|
+
let rec = { regressions: 0, gamed: 0, latest: null };
|
|
61
|
+
try { if (existsSync(recPath)) rec = JSON.parse(readFileSync(recPath, 'utf8')); } catch {}
|
|
62
|
+
let baseSha = ''; try { baseSha = git('rev-parse', `${base}^{commit}`); } catch {}
|
|
63
|
+
const standing = !!(rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.base === baseSha);
|
|
64
|
+
const writeRec = () => { try { mkdirSync(recDir, { recursive: true }); writeFileSync(recPath, JSON.stringify(rec, null, 2) + '\n'); } catch {} };
|
|
65
|
+
if (verdict === 'pass' && standing) {
|
|
66
|
+
// the span the gamed verdict was measured over now passes from the source: the wall comes down
|
|
67
|
+
rec.latest = { sha: git('rev-parse', head).slice(0, 8), kind: 'verified', base: baseSha, file: rec.latest.file, reason: 'the source earned the green over the span the gamed verdict was measured on', at: new Date().toISOString(), by: by || null };
|
|
68
|
+
writeRec();
|
|
69
|
+
}
|
|
55
70
|
if (verdict !== 'gamed' && verdict !== 'fail') process.exit(0); // only breakage is worth recording
|
|
56
71
|
|
|
57
72
|
// map the engine verdict to Sawbuck's plain outcome words
|
|
@@ -73,17 +88,15 @@ const reason = kind === 'gamed'
|
|
|
73
88
|
// --- 3) record the finding in memory (unreviewed, machine-authored note) ----
|
|
74
89
|
// The Memory Contract's `record` capability, through the adapter (no direct engine shell here).
|
|
75
90
|
// Kept simple: a commit-attached digest note. A later pass could add --span grounding
|
|
76
|
-
// and true coupling (the fix diff -> "change X also needs Y").
|
|
77
|
-
|
|
91
|
+
// and true coupling (the fix diff -> "change X also needs Y"). A gamed verdict still standing
|
|
92
|
+
// over the same span is the same catch, not a new one: no second note, no second tick.
|
|
93
|
+
const repeatOfStanding = standing && kind === 'gamed';
|
|
94
|
+
if (!repeatOfStanding) memRecord(repo, { sha, reason: reason.slice(0, 280), side: 'diff', evidenceFile, by: 'sawbuck' });
|
|
78
95
|
|
|
79
96
|
// --- 4) tick the results record (the visible proof-of-work) -----------------
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
try { if (existsSync(recPath)) rec = JSON.parse(readFileSync(recPath, 'utf8')); } catch {}
|
|
84
|
-
if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1;
|
|
85
|
-
rec.latest = { sha: sha.slice(0, 8), kind, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString(), by: by || null };
|
|
86
|
-
try { mkdirSync(recDir, { recursive: true }); writeFileSync(recPath, JSON.stringify(rec, null, 2) + '\n'); } catch {}
|
|
97
|
+
if (!repeatOfStanding) { if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1; }
|
|
98
|
+
rec.latest = { sha: sha.slice(0, 8), kind, base: baseSha || null, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString(), by: by || null };
|
|
99
|
+
writeRec();
|
|
87
100
|
|
|
88
101
|
// --- 5) quiet by default (dispatch surfaces on cadence); one line to stderr -
|
|
89
102
|
process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -6,20 +6,26 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from 'node:fs';
|
|
9
|
-
import { join, dirname } from 'node:path';
|
|
9
|
+
import { join, dirname, resolve } from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
|
|
12
12
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
import { mandateHits, hashOf } from './contract.mjs';
|
|
13
|
+
import { mandateHits, hashOf, protectedDirty, protectedAmong } from './contract.mjs';
|
|
14
14
|
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
|
|
15
|
-
const repo = flag('--repo', process.cwd());
|
|
16
15
|
const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
|
|
17
16
|
const stop = process.argv.includes('--stop'); // wired as the Claude Code Stop hook: enforce the contract on a gamed turn
|
|
17
|
+
// user-level wiring passes no --repo: the hook runs in whatever cwd the session has, so it resolves the
|
|
18
|
+
// repository root itself (a subdirectory belongs to its repo) and leaves a non-repository alone: no
|
|
19
|
+
// state, no exclude block, nothing written where there is nothing to verify.
|
|
20
|
+
const userMode = !process.argv.includes('--repo');
|
|
21
|
+
const sh = (cwd, ...a) => { try { return execFileSync('git', ['-C', cwd, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
22
|
+
const repo = flag('--repo', '') || sh(process.cwd(), 'rev-parse', '--show-toplevel') || process.cwd();
|
|
23
|
+
const git = (...a) => sh(repo, ...a);
|
|
24
|
+
if (!git('rev-parse', '--git-dir')) process.exit(0);
|
|
18
25
|
let hookInput = {};
|
|
19
26
|
if (stop) { try { hookInput = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch {} } // the Stop hook's JSON on stdin (stop_hook_active, prompt_id, ...)
|
|
20
27
|
ensureExcluded(repo); // keep the record out of git status under ANY attach mode (incl. --user)
|
|
21
28
|
|
|
22
|
-
const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
|
|
23
29
|
const CHECK_TIMEOUT = +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 600000; // never hang a turn
|
|
24
30
|
const node = (f, a) => { try { return execFileSync(process.execPath, [f, ...a], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: CHECK_TIMEOUT, killSignal: 'SIGKILL' }); } catch (e) { return (e.stdout || '').toString(); } };
|
|
25
31
|
|
|
@@ -33,36 +39,25 @@ const stPath = join(stDir, 'state.json');
|
|
|
33
39
|
let st = { lastSha: '', turns: 0, shownTotal: 0 };
|
|
34
40
|
try { if (existsSync(stPath)) st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
35
41
|
|
|
36
|
-
// 1) new commit(s) since last turn → run the check on them
|
|
42
|
+
// 1) new commit(s) since last turn → run the check on them. A gamed verdict stands until the span it
|
|
43
|
+
// was measured over re-proves verified: while the latest record is gamed, the re-run keeps that
|
|
44
|
+
// record's base, so a follow-up commit (an amend, an empty commit, unrelated work) cannot clear the
|
|
45
|
+
// wall without the source fix the re-proof demands. Under user-level wiring the check runs only where
|
|
46
|
+
// a contract exists (--no-auto): nobody's whole test suite runs three times in a repo they never set up.
|
|
37
47
|
const head = git('rev-parse', 'HEAD');
|
|
48
|
+
const readRec = () => { try { return JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch { return null; } };
|
|
49
|
+
let rec = readRec();
|
|
38
50
|
if (head && st.lastSha && head !== st.lastSha) {
|
|
39
|
-
|
|
51
|
+
const standing = !!(rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.sha !== head.slice(0, 8));
|
|
52
|
+
node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', standing ? rec.latest.base : st.lastSha, '--head', head, ...(by ? ['--by', by] : []), ...(userMode ? ['--no-auto'] : [])]);
|
|
53
|
+
rec = readRec();
|
|
40
54
|
}
|
|
41
55
|
st.lastSha = head || st.lastSha;
|
|
42
56
|
st.turns += 1;
|
|
43
57
|
|
|
44
|
-
// 2)
|
|
45
|
-
let rec = null;
|
|
46
|
-
try { rec = JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch {}
|
|
58
|
+
// 2) the record of what the check has charted (read above, after any re-run)
|
|
47
59
|
const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) : 0;
|
|
48
60
|
const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n'); } catch {} };
|
|
49
|
-
// when the last prompt of this session was typed, from the transcript the hook names (null when unknown):
|
|
50
|
-
// the newest user row that carries text, not a tool result; the tail of the file is enough
|
|
51
|
-
function lastPromptTime(transcriptPath) {
|
|
52
|
-
try {
|
|
53
|
-
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
54
|
-
const text = readFileSync(transcriptPath, 'utf8'); const tail = text.length > 524288 ? text.slice(-524288) : text;
|
|
55
|
-
const lines = tail.split('\n');
|
|
56
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
57
|
-
let o; try { o = JSON.parse(lines[i]); } catch { continue; }
|
|
58
|
-
if (!o || o.type !== 'user' || !o.timestamp) continue;
|
|
59
|
-
const c = o.message && o.message.content;
|
|
60
|
-
const isText = typeof c === 'string' || (Array.isArray(c) && c.some((p) => p && p.type === 'text') && !c.some((p) => p && p.type === 'tool_result'));
|
|
61
|
-
if (isText) { const t = Date.parse(o.timestamp); return Number.isFinite(t) ? t : null; }
|
|
62
|
-
}
|
|
63
|
-
} catch {}
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
61
|
|
|
67
62
|
// 2b) THE WALL AT TURN LEVEL (Claude Code Stop hook only): a mandate is checked against the WORKING
|
|
68
63
|
// tree on every stop, not only on commits, because an agent usually ends a turn with uncommitted
|
|
@@ -70,34 +65,45 @@ function lastPromptTime(transcriptPath) {
|
|
|
70
65
|
// and send the agent back to undo its protected edits and make the fix elsewhere. The rules are the
|
|
71
66
|
// working tree's AND the committed contract's (editing the config in the same turn lifts nothing),
|
|
72
67
|
// and a protected file that was already dirty with the same content when the prompt began (the
|
|
73
|
-
// baseline `sawbuck turn` recorded) is the person's own work and does not count.
|
|
68
|
+
// baseline `sawbuck turn --hook` recorded) is the person's own work and does not count. A protected
|
|
69
|
+
// edit COMMITTED since the baseline's commit counts too: a commit mid-turn does not walk through.
|
|
74
70
|
if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
71
|
+
const now = new Date().toISOString();
|
|
75
72
|
const dirty = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const promptAt = lastPromptTime(hookInput.transcript_path);
|
|
80
|
-
const baseAt = Date.parse(st.turnBaseline && st.turnBaseline.at);
|
|
81
|
-
if (promptAt && baseAt && baseAt > promptAt + 30000) baseline = {};
|
|
73
|
+
const base = (st.turnBaseline && typeof st.turnBaseline === 'object') ? st.turnBaseline : null;
|
|
74
|
+
const baseline = (base && base.files && typeof base.files === 'object') ? base.files : {};
|
|
75
|
+
const committedSince = (base && base.sha && head && base.sha !== head) ? git('diff', '--name-only', base.sha, 'HEAD').split('\n').filter(Boolean) : [];
|
|
82
76
|
const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
|
|
83
77
|
const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
|
|
84
|
-
const changed = dirty.filter((f) => !untouched(f));
|
|
78
|
+
const changed = [...new Set([...dirty.filter((f) => !untouched(f)), ...committedSince])];
|
|
85
79
|
const leftAlone = dirty.filter(untouched);
|
|
86
80
|
const hits = changed.length ? mandateHits(repo, changed) : [];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
st.lastStopAt = now; // the next prompt may take a fresh baseline only because this stop happened
|
|
82
|
+
st.lastStopHead = head || null; // and where HEAD stood: a commit between turns is the person's, not the agent's
|
|
83
|
+
if (hits.length) {
|
|
84
|
+
// remember EVERY protected path THIS TURN changed (dirty past the baseline, or committed since
|
|
85
|
+
// it) and what it holds, uncapped, plus the commit the baseline stood at, so the next prompt
|
|
86
|
+
// cannot adopt any of it. Only the turn's own changes: a person's file the baseline spared stays
|
|
87
|
+
// spared, or the next clean turn would be blocked over work that was never the agent's.
|
|
88
|
+
const files = {};
|
|
89
|
+
for (const f of protectedAmong(repo, changed)) files[f] = hashOf(repo, f);
|
|
90
|
+
st.lastViolation = { at: now, sha: (base && base.sha) || null, files };
|
|
91
|
+
} else if (st.lastViolation) { delete st.lastViolation; }
|
|
92
|
+
saveState();
|
|
90
93
|
if (hits.length) {
|
|
91
94
|
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
92
|
-
|
|
95
|
+
// the budget is per prompt: prompt_id when the harness sends one, else the prompt's own timestamp
|
|
96
|
+
// (written by `sawbuck turn --hook`), never a constant that would spend the budget for good
|
|
97
|
+
const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
|
|
93
98
|
if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
|
|
94
99
|
const desc = hits.map((h) => `"${h.name}"${h.reason ? ' (' + h.reason + ')' : ''}: ${h.files.join(', ')}${h.committed ? ' [this rule is in the committed contract; editing promptwheel.config.json in the working tree does not lift it]' : ''}`).join('; ');
|
|
95
100
|
const spared = leftAlone.length ? ` Edits that were already there before this prompt were left out of this check and must stay as they are: ${leftAlone.join(', ')}.` : '';
|
|
101
|
+
const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach --claude again, which upgrades the hooks.)';
|
|
96
102
|
try { mkdirSync(stDir, { recursive: true }); appendFileSync(join(stDir, 'activity.jsonl'), JSON.stringify({ at: new Date().toISOString(), kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' }) + '\n'); } catch {}
|
|
97
103
|
if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
|
|
98
104
|
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
99
105
|
saveState();
|
|
100
|
-
process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a mandate in the contract was violated, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}` }));
|
|
106
|
+
process.stdout.write(JSON.stringify({ decision: 'block', reason: `Sawbuck: a mandate in the contract was violated, the turn is not done. Protected paths changed in this turn: ${desc}. Undo the changes YOU made under the protected paths and make the fix elsewhere; do not revert anything you did not change (the rule is a wall, not a reminder; the gate fails any change that touches them).${spared}${upgrade}` }));
|
|
101
107
|
process.exit(0);
|
|
102
108
|
}
|
|
103
109
|
saveState();
|
|
@@ -113,7 +119,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
113
119
|
const gamedThisTurn = rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.sha === (head || '').slice(0, 8);
|
|
114
120
|
if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
115
121
|
const MAX_BLOCKS = +(cfg.enforce?.maxBlocks) || 3;
|
|
116
|
-
const promptId = hookInput.prompt_id || 'none';
|
|
122
|
+
const promptId = hookInput.prompt_id || st.lastPromptAt || 'none';
|
|
117
123
|
if (st.blockPromptId !== promptId) { st.blockPromptId = promptId; st.blockCount = 0; }
|
|
118
124
|
st.shownTotal = total;
|
|
119
125
|
const where = rec.latest.file;
|
|
@@ -145,8 +151,10 @@ saveState();
|
|
|
145
151
|
// per-repo `detach` removes them.
|
|
146
152
|
function ensureExcluded(r) {
|
|
147
153
|
try {
|
|
148
|
-
|
|
149
|
-
const
|
|
154
|
+
// the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
|
|
155
|
+
const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
|
|
156
|
+
if (!rel) return;
|
|
157
|
+
const f = resolve(r, rel);
|
|
150
158
|
// the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
|
|
151
159
|
// the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
|
|
152
160
|
const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n# <<< sawbuck <<<\n';
|
package/sawbuck.mjs
CHANGED
|
@@ -101,6 +101,15 @@ Usage:
|
|
|
101
101
|
Engines: promptwheel (the check) + @promptwheel/logbook (memory). Zero-LLM, offline, free.`;
|
|
102
102
|
|
|
103
103
|
const die = (msg) => { process.stderr.write(`sawbuck: ${msg}\n`); process.exit(1); };
|
|
104
|
+
const usage = (msg) => { process.stderr.write(`sawbuck: ${msg}\n`); process.exit(64); }; // could not run: never a verdict code
|
|
105
|
+
// the repository a path belongs to (its toplevel), so a command run from a subdirectory lands at the
|
|
106
|
+
// root: hooks pinned to the root, the receipt and the exclude under its git dir. '' outside a repo.
|
|
107
|
+
function gitTop(p) { try { return execFileSync('git', ['-C', p, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } }
|
|
108
|
+
const repoRoot = () => gitTop(repoArg()) || repoArg();
|
|
109
|
+
// paths inside the repository's git dir, asked of git: a worktree or a submodule keeps its git dir
|
|
110
|
+
// elsewhere (.git is a FILE there), so join(repo, '.git', ...) is wrong in exactly the repos where it hurts
|
|
111
|
+
function gitPath(repo, rel) { try { return resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', rel], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return join(repo, '.git', rel); } }
|
|
112
|
+
function gitDir(repo) { try { return resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-dir'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return join(repo, '.git'); } }
|
|
104
113
|
const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
|
|
105
114
|
|
|
106
115
|
if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
|
|
@@ -210,9 +219,12 @@ switch (cmd) {
|
|
|
210
219
|
// fires mid-turn), remember which protected paths were already dirty and what they held, so the
|
|
211
220
|
// Stop hook's wall counts only what this turn changes and leaves a person's own edits alone.
|
|
212
221
|
// Only under --hook (the wiring passes it) is stdin read for the hook's JSON, so a plain
|
|
213
|
-
// `sawbuck turn` never waits on a pipe.
|
|
214
|
-
//
|
|
215
|
-
//
|
|
222
|
+
// `sawbuck turn` never waits on a pipe. Three rules keep the baseline honest: a new baseline is
|
|
223
|
+
// taken only when the previous turn actually reached a Stop (an interrupt, a crash, or a re-run of
|
|
224
|
+
// this command mid-turn leaves the last one standing, so nothing the agent left behind is adopted);
|
|
225
|
+
// a file still carrying the content the last violation flagged is never baselined; and the
|
|
226
|
+
// baseline's commit does not advance past a flagged commit until a clean stop or a commit the
|
|
227
|
+
// person made between turns.
|
|
216
228
|
let hookEvent = '';
|
|
217
229
|
if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
218
230
|
if (hookEvent === 'UserPromptSubmit') {
|
|
@@ -220,10 +232,23 @@ switch (cmd) {
|
|
|
220
232
|
if (mandatesInForce(repo).length) {
|
|
221
233
|
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
222
234
|
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
235
|
+
const now = new Date().toISOString();
|
|
236
|
+
const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
|
|
237
|
+
const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
|
|
238
|
+
const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
|
|
239
|
+
if (!unfinished) {
|
|
240
|
+
const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
|
|
241
|
+
const files = protectedDirty(repo);
|
|
242
|
+
for (const f of Object.keys(files)) if (flagged[f] === files[f]) delete files[f]; // the agent's un-reverted edit stays on the wall
|
|
243
|
+
let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
|
|
244
|
+
// HEAD moved since the last Stop: the agent was not running, so the commits between turns
|
|
245
|
+
// are the person's (accepting a change, a pull, a branch switch) and the baseline starts
|
|
246
|
+
// there. Otherwise the baseline's commit does not advance past a flagged commit.
|
|
247
|
+
const moved = !!(sha && st.lastStopHead && sha !== st.lastStopHead);
|
|
248
|
+
if (moved && st.lastViolation) delete st.lastViolation.sha;
|
|
249
|
+
st.turnBaseline = { at: now, sha: (!moved && st.lastViolation && st.lastViolation.sha) || sha, files };
|
|
250
|
+
}
|
|
251
|
+
st.lastPromptAt = now;
|
|
227
252
|
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
228
253
|
}
|
|
229
254
|
} catch { /* the frame never fails a prompt */ }
|
|
@@ -263,8 +288,11 @@ switch (cmd) {
|
|
|
263
288
|
break;
|
|
264
289
|
}
|
|
265
290
|
case 'attach': {
|
|
266
|
-
const
|
|
267
|
-
|
|
291
|
+
const user = flags.has('--user');
|
|
292
|
+
const top = gitTop(repoArg());
|
|
293
|
+
if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
|
|
294
|
+
const repo = top || repoArg();
|
|
295
|
+
if (user) { wireClaude(repo, { user: true }); if (!dry) verifyAttach(repo); break; }
|
|
268
296
|
if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); if (!dry) verifyAttach(repo); break; }
|
|
269
297
|
wireGit(repo);
|
|
270
298
|
if (!dry) verifyAttach(repo);
|
|
@@ -273,7 +301,7 @@ switch (cmd) {
|
|
|
273
301
|
case 'detach': {
|
|
274
302
|
const purge = flags.has('--purge');
|
|
275
303
|
if (flags.has('--all')) { detachAll(purge); break; }
|
|
276
|
-
const repo =
|
|
304
|
+
const repo = repoRoot();
|
|
277
305
|
if (flags.has('--user')) unwireClaude(repo, { user: true });
|
|
278
306
|
else if (flags.has('--claude')) unwireClaude(repo, {});
|
|
279
307
|
else {
|
|
@@ -288,8 +316,8 @@ switch (cmd) {
|
|
|
288
316
|
if (flags.has('--verify')) verifyClean(repo);
|
|
289
317
|
break;
|
|
290
318
|
}
|
|
291
|
-
case 'trace': { traceRepo(
|
|
292
|
-
case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(
|
|
319
|
+
case 'trace': { traceRepo(repoRoot()); break; }
|
|
320
|
+
case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
|
|
293
321
|
case 'where': { whereAttached(); break; }
|
|
294
322
|
case 'statusline': {
|
|
295
323
|
// compact status-line segment for the cwd repo. Never throws; fast.
|
|
@@ -352,7 +380,8 @@ function contextBlock(repo, speak) {
|
|
|
352
380
|
// run the gate on the last commit and say what it can or cannot measure, so you never attach
|
|
353
381
|
// into silent nothing. Best-effort and bounded; the wire already succeeded regardless.
|
|
354
382
|
function verifyAttach(repo) {
|
|
355
|
-
|
|
383
|
+
// stderr ignored: a benign failing probe (rev-parse HEAD~1 on a one-commit repo) must not leak a "fatal:" line
|
|
384
|
+
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
356
385
|
if (!g('rev-parse', 'HEAD')) { out(' attached. Make a commit, then run `sawbuck check` to see it work.'); return; }
|
|
357
386
|
out(' verifying I can measure here (one-time)...');
|
|
358
387
|
const base = g('rev-parse', '--verify', 'HEAD~1') ? 'HEAD~1' : 'HEAD';
|
|
@@ -363,7 +392,7 @@ function verifyAttach(repo) {
|
|
|
363
392
|
}
|
|
364
393
|
|
|
365
394
|
// --- the attach receipt (out of the worktree, inside .git) ------------------
|
|
366
|
-
function manifestPath(repo) { return join(repo, '
|
|
395
|
+
function manifestPath(repo) { return join(gitDir(repo), 'sawbuck', 'attach.json'); }
|
|
367
396
|
function userManifestPath() { return join(homedir(), '.sawbuck', 'user-attach.json'); }
|
|
368
397
|
function readManifest(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { version: 1, attachments: {} }; } }
|
|
369
398
|
function writeManifest(p, m) {
|
|
@@ -378,7 +407,7 @@ function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length ===
|
|
|
378
407
|
// --- .git/info/exclude: keep the worktree clean without touching .gitignore --
|
|
379
408
|
// Rewrites a single marked block to the union of excludes the current
|
|
380
409
|
// attachments need. Never deletes the file (an empty one is git's own default).
|
|
381
|
-
function excludeFile(repo) { return
|
|
410
|
+
function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
|
|
382
411
|
function syncExclude(repo, manifest) {
|
|
383
412
|
const need = new Set();
|
|
384
413
|
const a = manifest.attachments || {};
|
|
@@ -408,8 +437,8 @@ function hookBlock(repo) {
|
|
|
408
437
|
return `${S_START}\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_COMMON_DIR\nif command -v sawbuck >/dev/null 2>&1; then sawbuck __hook --repo ${r}; else node ${JSON.stringify(SELF)} __hook --repo ${r}; fi || true\n${S_END}\n`;
|
|
409
438
|
}
|
|
410
439
|
function wireGit(repo) {
|
|
411
|
-
if (!
|
|
412
|
-
const pc =
|
|
440
|
+
if (!gitTop(repo)) usage(`${repo} is not a git repository`);
|
|
441
|
+
const pc = gitPath(repo, 'hooks/post-commit');
|
|
413
442
|
const existed = existsSync(pc);
|
|
414
443
|
let body = existed ? readFileSync(pc, 'utf8') : '#!/bin/sh\n';
|
|
415
444
|
if (body.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
|
|
@@ -433,7 +462,7 @@ function wireGit(repo) {
|
|
|
433
462
|
out(`sawbuck: attached (git post-commit) in ${repo}`);
|
|
434
463
|
}
|
|
435
464
|
function unwireGit(repo) {
|
|
436
|
-
const pc =
|
|
465
|
+
const pc = gitPath(repo, 'hooks/post-commit');
|
|
437
466
|
const m = readManifest(manifestPath(repo));
|
|
438
467
|
const hasReceipt = !!m.attachments?.git;
|
|
439
468
|
if (!existsSync(pc) && !hasReceipt) { out('sawbuck: nothing to detach'); return; }
|
|
@@ -550,7 +579,7 @@ function isTrivialSettings(cfg) { return Object.keys(cfg).length === 0; }
|
|
|
550
579
|
// --- trace / verify / doctor / purge: show, prove, and clean the footprint --
|
|
551
580
|
function footprint(repo) {
|
|
552
581
|
const items = [];
|
|
553
|
-
const pc =
|
|
582
|
+
const pc = gitPath(repo, 'hooks/post-commit');
|
|
554
583
|
try { if (existsSync(pc) && readFileSync(pc, 'utf8').includes(S_START)) items.push({ what: pc, state: 'git hook' }); } catch {}
|
|
555
584
|
for (const f of [join(repo, '.claude', 'settings.local.json'), join(repo, '.claude', 'settings.json'), join(homedir(), '.claude', 'settings.json')]) {
|
|
556
585
|
try { if (existsSync(f) && readFileSync(f, 'utf8').includes('sawbuck.mjs')) items.push({ what: f, state: 'claude hooks' }); } catch {}
|
|
@@ -591,7 +620,7 @@ function onPath() {
|
|
|
591
620
|
function extractBaked(text) { const m = text.match(/node "([^"]+)"/); return m ? m[1] : null; }
|
|
592
621
|
function doctorRepo(repo) {
|
|
593
622
|
const checks = [];
|
|
594
|
-
const pc =
|
|
623
|
+
const pc = gitPath(repo, 'hooks/post-commit');
|
|
595
624
|
try { if (existsSync(pc)) { const b = readFileSync(pc, 'utf8'); if (b.includes(S_START)) checks.push({ mode: 'git', file: pc, baked: extractBaked(b) }); } } catch {}
|
|
596
625
|
for (const [mode, f] of [['claude', join(repo, '.claude', 'settings.local.json')], ['claude-shared', join(repo, '.claude', 'settings.json')]]) {
|
|
597
626
|
try { if (existsSync(f)) { const b = readFileSync(f, 'utf8'); if (b.includes('sawbuck.mjs')) checks.push({ mode, file: f, baked: extractBaked(b) }); } } catch {}
|
package/status.mjs
CHANGED
|
@@ -35,6 +35,10 @@ const seen = feed.length;
|
|
|
35
35
|
const catchesAllTime = (results.regressions || 0) + (results.gamed || 0);
|
|
36
36
|
const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
|
|
37
37
|
const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
|
|
38
|
+
// the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file; the
|
|
39
|
+
// recent-window average over roughly the last twenty scored turns, as the band and statusline show it)
|
|
40
|
+
const steerRec = readJSON(join(repo, '.sawbuck', 'steer.json'));
|
|
41
|
+
const steer = steerRec && steerRec.scored ? { rate: Math.round(((steerRec.ema != null ? steerRec.ema : steerRec.steeringRate) || 0) * 100), scored: steerRec.scored } : null;
|
|
38
42
|
|
|
39
43
|
// --- 2) the armed state: what it can measure here (mirror of statusline) ---
|
|
40
44
|
const isGit = !!existsSync(join(repo, '.git'));
|
|
@@ -65,6 +69,7 @@ if (asJson) {
|
|
|
65
69
|
git: isGit,
|
|
66
70
|
config: hasConfig,
|
|
67
71
|
outcomes: { seen, caughtAllTime: catchesAllTime, gamedAllTime: results.gamed || 0, regressionsAllTime: results.regressions || 0, recent: tally, latest, since },
|
|
72
|
+
steer,
|
|
68
73
|
debt: debt ? { total: debt.total, testSkips: debt.testSkips, undocumented: debt.undocumented, filesOnFragileCode: debt.filesOnFragileCode, top: (debt.files || []).slice(0, 5) } : null,
|
|
69
74
|
debtError: debtErr,
|
|
70
75
|
}, null, 2));
|
|
@@ -105,6 +110,12 @@ if (seen || catchesAllTime) {
|
|
|
105
110
|
out.push(` Caught for you · nothing recorded yet (the check logs here on each commit/turn)`);
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
// Steering (only when the judge has scored turns here)
|
|
114
|
+
if (steer) {
|
|
115
|
+
out.push('');
|
|
116
|
+
out.push(` Steer rate · ${steer.rate}% of your recent turns redirected the agent (${steer.scored} scored, recent-window average)`);
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
// Debt
|
|
109
120
|
out.push('');
|
|
110
121
|
if (debt && debt.total) {
|