sawbuck 0.1.31 → 0.1.33
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 +1 -1
- package/README.md +3 -3
- package/contract.mjs +24 -6
- package/package.json +3 -3
- package/reflect.mjs +23 -7
- package/sawbuck-hook.mjs +45 -5
- package/sawbuck.mjs +38 -21
package/CONTRACT.md
CHANGED
|
@@ -77,7 +77,7 @@ This is the part that makes "moving the goalposts" objective rather than a judgm
|
|
|
77
77
|
|
|
78
78
|
A win is **`gamed`** when it does not survive a **source-only re-run**: revert every non-source change the agent made, re-measure, and see how much of the gain remains (`retained`). If `retained` falls below `gamingThreshold` (default `0.5`), the verdict is `gamed`.
|
|
79
79
|
|
|
80
|
-
- **Non-source** (reverted before the re-run): tests, specs, fixtures, snapshots and golden files, graders, config,
|
|
80
|
+
- **Non-source** (reverted before the re-run): tests, specs, fixtures, snapshots and golden files, graders, config, plus any path declared in `evaluatorPaths`.
|
|
81
81
|
- **Source**: everything else, the code the change was supposed to be about.
|
|
82
82
|
- **`evaluatorPaths`**: an opt-in allowlist a repo declares for its own gate files (a threshold file, a budget, a ruleset), because a generic name is a classifier risk and only the repo owner knows which files are the goalposts.
|
|
83
83
|
|
package/README.md
CHANGED
|
@@ -34,16 +34,16 @@ $ sawbuck check # after fixing the source and restoring the test
|
|
|
34
34
|
🐴 ✓ verified: your working change earned it. "tests_pass" held, and the source carried the win.
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `64` could not run (not a git repo, no commit
|
|
37
|
+
Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `64` could not run (not a git repo, no commit); no detectable test command reads `3` inconclusive.
|
|
38
38
|
|
|
39
39
|
## What it does
|
|
40
40
|
|
|
41
|
-
- **Verifies the win.** `check` re-runs your metric from the source diff alone. A faked green (a deleted, weakened, or skipped test; a mocked grader; an edited golden file; a loosened config) reads `gamed`. Deterministic, zero-LLM, $0, and it cannot be argued with because it never reads the trajectory. Red-teamed across 9
|
|
41
|
+
- **Verifies the win.** `check` re-runs your metric from the source diff alone. A faked green (a deleted, weakened, or skipped test; a mocked grader; an edited golden file; a loosened config) reads `gamed`. Deterministic, zero-LLM, $0, and it cannot be argued with because it never reads the trajectory. Red-teamed across 9 ecosystems and 26 adversarial scenarios ([promptwheel/bench/REDTEAM.md](https://github.com/promptwheel-ai/promptwheel/blob/main/bench/REDTEAM.md)).
|
|
42
42
|
- **Says what it cannot see.** A cheat that lives inside the source itself (a value hardcoded to the one visible case, logic keyed to the test environment) is invisible to a source-only re-run, by construction. That limit is in [the contract](CONTRACT.md#3-the-integrity-boundary-what-counts-as-gaming), not hidden.
|
|
43
43
|
- **Remembers what broke.** `context` surfaces the relevant history (reverts, fragile areas, test-weakening) for the files you are touching: a ranked trail guide, before you act.
|
|
44
44
|
- **Shows you everything.** `status` is the inventory: what it has caught for you over time, the debt accruing now, and whether it is armed here, in one view. On-demand, so it is quiet until you go looking.
|
|
45
45
|
- **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
|
|
46
|
-
- **Clean on and off.** `attach` touches nothing in your worktree (the hook lives in `.git/`, the record is excluded locally); `detach` is a true inverse. `trace` shows every byte it placed, and `detach --verify` proves the repo came back unchanged.
|
|
46
|
+
- **Clean on and off.** the default `attach` touches nothing in your worktree (the hook lives in `.git/`, the record is excluded locally); `--claude` writes one git-excluded settings file; `detach` is a true inverse. `trace` shows every byte it placed, and `detach --verify` proves the repo came back unchanged.
|
|
47
47
|
|
|
48
48
|
## Commands
|
|
49
49
|
|
package/contract.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// contract.mjs: the contract's hard rules, read the way promptwheel reads them. Shared by the CLI
|
|
2
2
|
// (frame / turn) and the Stop hook (the turn-level wall), so there is one resolver and one renderer.
|
|
3
3
|
// Read-only, never throws, zero-dep.
|
|
4
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
5
5
|
import { join, dirname, resolve, isAbsolute, posix } from 'node:path';
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
7
|
|
|
@@ -59,24 +59,32 @@ export function protectedAmong(repo, paths) {
|
|
|
59
59
|
return paths.filter((f) => res.some((re) => re.test(f)));
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// a working-tree file's content hash as git would store it; 'deleted' when it is gone
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// a working-tree file's content hash as git would store it; 'deleted' when it is gone. With `store`,
|
|
63
|
+
// the content is also written into the object database (`hash-object -w`), so a person's edit that
|
|
64
|
+
// the baseline recorded can be restored by hash later even after the agent overwrote the file.
|
|
65
|
+
export function hashOf(repo, file, { store = false } = {}) {
|
|
66
|
+
try { return gitOut(repo, ['hash-object', ...(store ? ['-w'] : []), '--', file]).trim(); } catch { return 'deleted'; }
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
// the protected paths that are dirty right now, each with its content hash: the baseline `turn`
|
|
68
70
|
// records when a prompt begins, so the Stop hook counts only what the turn itself changed and leaves
|
|
69
71
|
// a person's own pre-existing edits alone. {} when nothing protected is dirty (or not a git repo).
|
|
70
|
-
|
|
72
|
+
// `store` keeps each file's content in the object database (see hashOf), which the baseline does.
|
|
73
|
+
export function protectedDirty(repo, { store = false } = {}) {
|
|
71
74
|
let changed = [];
|
|
72
75
|
try { changed = [...new Set([...gitOut(repo, ['diff', '--name-only', 'HEAD']).split('\n'), ...gitOut(repo, ['ls-files', '--others', '--exclude-standard']).split('\n')].filter(Boolean))]; }
|
|
73
76
|
catch { return {}; }
|
|
74
77
|
const res = mandatesInForce(repo).flatMap((m) => pathsOf(m).map(globToRe));
|
|
75
78
|
const out = {};
|
|
76
|
-
for (const f of changed) if (res.some((re) => re.test(f))) out[f] = hashOf(repo, f);
|
|
79
|
+
for (const f of changed) if (res.some((re) => re.test(f))) out[f] = hashOf(repo, f, { store });
|
|
77
80
|
return out;
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
// the blob hash a tracked path has at `ref` ('' when the path is not there)
|
|
84
|
+
export function blobAt(repo, ref, file) {
|
|
85
|
+
try { return gitOut(repo, ['rev-parse', `${ref}:${file}`]).trim(); } catch { return ''; }
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
// the same glob dialect as promptwheel's evaluatorPaths / mandates (ported verbatim): a bare name
|
|
81
89
|
// matches that basename at any depth; a pattern with "/" is anchored at the repo root; "*" stops at
|
|
82
90
|
// "/", "**" crosses it, "?" is one char; a leading "/" anchors to the root explicitly.
|
|
@@ -116,3 +124,13 @@ export function frameBlock(repo) {
|
|
|
116
124
|
const lines = mandates.map((m) => ` ${m.paths.filter((s) => typeof s === 'string' && s.trim()).join(' · ')}${m.reason ? ` (${String(m.reason).slice(0, 160)})` : ''}`);
|
|
117
125
|
return '▣ contract (sawbuck) · hard rules in force this session, enforced by the gate on every turn:\n protected, do not touch:\n' + lines.join('\n');
|
|
118
126
|
}
|
|
127
|
+
|
|
128
|
+
// the repo's architecture lock, if the supply-chain preset is installed: structure the lock has not seen, and a
|
|
129
|
+
// fingerprint of the resolved dependency tree. null when there is no lock here. Read-only, and fast (under 200 ms
|
|
130
|
+
// on the largest repo tried), so it can run at every turn end.
|
|
131
|
+
export function lockState(repo) {
|
|
132
|
+
const tool = join(repo, '.promptwheel', 'packs', 'supply-chain', 'lock.mjs');
|
|
133
|
+
if (!existsSync(tool) || !existsSync(join(repo, 'promptwheel.lock.json'))) return null;
|
|
134
|
+
const run = (...a) => execFileSync(process.execPath, [tool, ...a], { cwd: repo, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
135
|
+
try { return { drift: JSON.parse(run('drift', '.', '--json')), pins: run('pins', '.') }; } catch { return null; }
|
|
136
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
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",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"sawbuck": "sawbuck.mjs"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@promptwheel/logbook": "
|
|
37
|
-
"promptwheel": "
|
|
36
|
+
"@promptwheel/logbook": "0.9.10",
|
|
37
|
+
"promptwheel": "0.7.11"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"sawbuck.mjs",
|
package/reflect.mjs
CHANGED
|
@@ -35,7 +35,10 @@ const noAuto = process.argv.includes('--no-auto'); // user-level wiring: measu
|
|
|
35
35
|
const git = (...a) => execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim();
|
|
36
36
|
// backstop so the check can never hang a commit/turn; generous default, overridable.
|
|
37
37
|
const CHECK_TIMEOUT = +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 600000;
|
|
38
|
-
|
|
38
|
+
// a commit an agent made: its own Promptwheel-Allow trailer does not clear the verdict. An agent can write a
|
|
39
|
+
// commit message, so the override for its work is a person running `sawbuck check --allow "<reason>"`.
|
|
40
|
+
const childEnv = by ? { ...process.env, PROMPTWHEEL_NO_ALLOW_TRAILER: '1' } : process.env;
|
|
41
|
+
const runNode = (file, a) => execFileSync(process.execPath, [file, ...a], { cwd: repo, encoding: 'utf8', env: childEnv, stdio: ['ignore', 'pipe', 'pipe'], timeout: CHECK_TIMEOUT, killSignal: 'SIGKILL' });
|
|
39
42
|
|
|
40
43
|
// --- 1) the check: measure the commit, parse the structured result ----------
|
|
41
44
|
// The check exits nonzero on fail/gamed/inconclusive, but still prints the JSON
|
|
@@ -52,7 +55,6 @@ function check() {
|
|
|
52
55
|
const report = check();
|
|
53
56
|
if (!report || !report.verdict) process.exit(0); // couldn't measure → nothing to learn
|
|
54
57
|
const verdict = report.verdict; // engine contract: 'gamed' | 'fail' | ...
|
|
55
|
-
appendActivity(verdict, report); // the event feed logs EVERY measurable check, so you can watch it work (verified too, not only catches)
|
|
56
58
|
// the results record, and the base this run measured from (a full sha), so a gamed verdict can be
|
|
57
59
|
// held against the SAME span until it re-proves: the hook re-runs base..HEAD, not last-commit..HEAD
|
|
58
60
|
const recDir = join(repo, '.sawbuck');
|
|
@@ -62,10 +64,23 @@ try { if (existsSync(recPath)) rec = JSON.parse(readFileSync(recPath, 'utf8'));
|
|
|
62
64
|
let baseSha = ''; try { baseSha = git('rev-parse', `${base}^{commit}`); } catch {}
|
|
63
65
|
const standing = !!(rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.base === baseSha);
|
|
64
66
|
const writeRec = () => { try { mkdirSync(recDir, { recursive: true }); writeFileSync(recPath, JSON.stringify(rec, null, 2) + '\n'); } catch {} };
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
appendActivity(verdict, report, standing); // the event feed logs EVERY measurable check, so you can watch it work (verified too, not only catches)
|
|
68
|
+
if (standing) {
|
|
69
|
+
let headSha = ''; try { headSha = git('rev-parse', head); } catch {}
|
|
70
|
+
if (verdict === 'pass') {
|
|
71
|
+
// the span the gamed verdict was measured over now passes from the source: the wall comes down
|
|
72
|
+
rec.latest = { sha: headSha.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 };
|
|
73
|
+
writeRec();
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
// gamed again, a regression on top, or a span that cannot be certified: the source has still not
|
|
77
|
+
// earned the green over the span the verdict was measured on, so the gamed record holds, advanced
|
|
78
|
+
// to this head (the Stop hook keeps the wall up), its file and attribution untouched (a commit
|
|
79
|
+
// the person made in between is not the catch), and nothing is counted or noted a second time
|
|
80
|
+
rec.latest = { ...rec.latest, sha: headSha.slice(0, 8), at: new Date().toISOString(), standingVerdict: verdict };
|
|
68
81
|
writeRec();
|
|
82
|
+
process.stderr.write(`sawbuck: the gamed verdict on ${rec.latest.file} still stands (${verdict} over the same span)\n`);
|
|
83
|
+
process.exit(0);
|
|
69
84
|
}
|
|
70
85
|
if (verdict !== 'gamed' && verdict !== 'fail') process.exit(0); // only breakage is worth recording
|
|
71
86
|
|
|
@@ -104,7 +119,7 @@ process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
|
|
|
104
119
|
// --- the activity feed: one line per check so you can watch it work, verified
|
|
105
120
|
// included, not only catches. Capped, lives in the git-excluded .sawbuck/, opt
|
|
106
121
|
// out with SAWBUCK_NO_LOG=1. `sawbuck log` reads it.
|
|
107
|
-
function appendActivity(v, rep) {
|
|
122
|
+
function appendActivity(v, rep, standing = false) {
|
|
108
123
|
if (process.env.SAWBUCK_NO_LOG) return;
|
|
109
124
|
const map = { pass: 'verified', gamed: 'gamed', fail: 'regression', inconclusive: 'inconclusive' };
|
|
110
125
|
const k = (v === 'pass' && rep.allowed) ? 'allowed' : map[v]; // a declared override is logged distinctly, for the audit trail
|
|
@@ -118,7 +133,8 @@ function appendActivity(v, rep) {
|
|
|
118
133
|
: k === 'allowed' ? ('declared intentional: ' + (rep.allowed?.reason || ''))
|
|
119
134
|
: k === 'verified' ? 'the source earned the green'
|
|
120
135
|
: 'could not certify a pass';
|
|
121
|
-
|
|
136
|
+
// `standing`: this check re-proved the span of a gamed verdict that is still up, not a new catch
|
|
137
|
+
const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160), by: by || null, ...(standing ? { standing: true } : {}) };
|
|
122
138
|
const dir = join(repo, '.sawbuck');
|
|
123
139
|
const p = join(dir, 'activity.jsonl');
|
|
124
140
|
try {
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -10,7 +10,7 @@ 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, protectedDirty, protectedAmong } from './contract.mjs';
|
|
13
|
+
import { mandateHits, hashOf, protectedDirty, protectedAmong, lockState } from './contract.mjs';
|
|
14
14
|
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
|
|
15
15
|
const by = flag('--by', ''); // attribution from the harness wiring: the Claude hook passes 'claude', the raw-git hook passes nothing
|
|
16
16
|
const stop = process.argv.includes('--stop'); // wired as the Claude Code Stop hook: enforce the contract on a gamed turn
|
|
@@ -46,9 +46,21 @@ try { if (existsSync(stPath)) st = JSON.parse(readFileSync(stPath, 'utf8')); } c
|
|
|
46
46
|
// a contract exists (--no-auto): nobody's whole test suite runs three times in a repo they never set up.
|
|
47
47
|
const head = git('rev-parse', 'HEAD');
|
|
48
48
|
const readRec = () => { try { return JSON.parse(readFileSync(join(stDir, 'results.json'), 'utf8')); } catch { return null; } };
|
|
49
|
+
const writeRec = (r) => { try { mkdirSync(stDir, { recursive: true }); writeFileSync(join(stDir, 'results.json'), JSON.stringify(r, null, 2) + '\n'); } catch {} };
|
|
50
|
+
const logActivity = (ev) => { try { mkdirSync(stDir, { recursive: true }); appendFileSync(join(stDir, 'activity.jsonl'), JSON.stringify({ at: new Date().toISOString(), ...ev }) + '\n'); } catch {} };
|
|
51
|
+
const hasCommit = (ref) => { try { execFileSync('git', ['-C', repo, 'cat-file', '-e', `${ref}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
49
52
|
let rec = readRec();
|
|
50
53
|
if (head && st.lastSha && head !== st.lastSha) {
|
|
51
|
-
|
|
54
|
+
let standing = !!(rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.sha !== head.slice(0, 8));
|
|
55
|
+
if (standing && !hasCommit(rec.latest.base)) {
|
|
56
|
+
// the span can no longer be re-proved (a rebase, a squash, a pruned branch took its base away):
|
|
57
|
+
// say so in the feed, drop the record to unverified (never blocked on), and keep checking new
|
|
58
|
+
// commits from the last one seen, so the wall's loss is visible instead of a silent stop
|
|
59
|
+
logActivity({ kind: 'note', sha: head.slice(0, 8), file: rec.latest.file || '', reason: 'the base of the standing gamed verdict is gone from this clone (rebased or pruned); the record is cleared and new commits are checked from the last one seen', by: by || 'hook' });
|
|
60
|
+
rec.latest = { ...rec.latest, kind: 'unverified', at: new Date().toISOString(), reason: 'the base of the gamed verdict left the clone before the span re-proved from the source' };
|
|
61
|
+
writeRec(rec);
|
|
62
|
+
standing = false;
|
|
63
|
+
}
|
|
52
64
|
node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', standing ? rec.latest.base : st.lastSha, '--head', head, ...(by ? ['--by', by] : []), ...(userMode ? ['--no-auto'] : [])]);
|
|
53
65
|
rec = readRec();
|
|
54
66
|
}
|
|
@@ -87,7 +99,12 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
87
99
|
// spared, or the next clean turn would be blocked over work that was never the agent's.
|
|
88
100
|
const files = {};
|
|
89
101
|
for (const f of protectedAmong(repo, changed)) files[f] = hashOf(repo, f);
|
|
90
|
-
|
|
102
|
+
// a flagged file the person had dirty before the prompt: keep the hash of THEIR content (the
|
|
103
|
+
// baseline stored it in the object database), so the block can name the exact restore and the
|
|
104
|
+
// next baseline can recognise the restored file as theirs
|
|
105
|
+
const before = {};
|
|
106
|
+
for (const f of Object.keys(files)) if (Object.prototype.hasOwnProperty.call(baseline, f) && baseline[f] && baseline[f] !== 'deleted') before[f] = baseline[f];
|
|
107
|
+
st.lastViolation = { at: now, sha: (base && base.sha) || null, files, ...(Object.keys(before).length ? { before } : {}) };
|
|
91
108
|
} else if (st.lastViolation) { delete st.lastViolation; }
|
|
92
109
|
saveState();
|
|
93
110
|
if (hits.length) {
|
|
@@ -98,12 +115,16 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
98
115
|
if (st.mandatePromptId !== promptId) { st.mandatePromptId = promptId; st.mandateBlocks = 0; }
|
|
99
116
|
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('; ');
|
|
100
117
|
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(', ')}.` : '';
|
|
118
|
+
const before = (st.lastViolation && st.lastViolation.before) || {};
|
|
119
|
+
const restore = Object.keys(before).length ? ' To put back what you overwrote: ' + Object.entries(before).map(([f, h]) => `restore ${f} to the content it had when the prompt began: git cat-file -p ${h} > ${f}`).join('; ') + '.' : '';
|
|
120
|
+
const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
|
|
121
|
+
const accept = committedHit ? ' To accept this change on purpose, change the mandate in the contract or revert the path.' : '';
|
|
101
122
|
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.)';
|
|
102
|
-
|
|
123
|
+
logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
|
|
103
124
|
if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
|
|
104
125
|
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
105
126
|
saveState();
|
|
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}` }));
|
|
127
|
+
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}${restore}${accept}${upgrade}` }));
|
|
107
128
|
process.exit(0);
|
|
108
129
|
}
|
|
109
130
|
saveState();
|
|
@@ -135,6 +156,25 @@ if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
135
156
|
process.exit(0);
|
|
136
157
|
}
|
|
137
158
|
|
|
159
|
+
// 3b) THE LOCK AT TURN LEVEL: tell the person the moment a turn changes the repo's structure or its resolved
|
|
160
|
+
// dependencies. A notice, not a block: a person who asked for a new dependency should not fight the wall, and
|
|
161
|
+
// the gate in CI is the hard stop, which needs their reason. Said once per change.
|
|
162
|
+
if (stop && !process.env.SAWBUCK_NO_ENFORCE && st.turnBaseline && st.turnBaseline.lock) {
|
|
163
|
+
const was = st.turnBaseline.lock, now = lockState(repo);
|
|
164
|
+
if (now) {
|
|
165
|
+
const known = new Set(was.drift || []);
|
|
166
|
+
const fresh = (now.drift || []).filter((i) => !known.has(i));
|
|
167
|
+
const pinsMoved = !!now.pins && now.pins !== was.pins;
|
|
168
|
+
const key = JSON.stringify([fresh, pinsMoved ? now.pins : '']);
|
|
169
|
+
if ((fresh.length || pinsMoved) && st.lockNotice !== key) {
|
|
170
|
+
st.lockNotice = key; saveState();
|
|
171
|
+
const what = [...fresh.slice(0, 8), ...(pinsMoved ? ['the resolved dependency tree'] : [])].join(', ');
|
|
172
|
+
process.stdout.write(JSON.stringify({ systemMessage: `Sawbuck: this turn changed what the lock holds: ${what}${fresh.length > 8 ? `, and ${fresh.length - 8} more` : ''}. If you did not ask for that, look before you commit. The gate needs your reason to pass it: promptwheel run --allow "<reason>".` }));
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
138
178
|
// 4) otherwise, surface a catch on a fresh catch or on cadence (a flag; never blocks)
|
|
139
179
|
const freshCatch = total > (st.shownTotal || 0);
|
|
140
180
|
const cadenceHit = everyTurns > 0 && st.turns % everyTurns === 0;
|
package/sawbuck.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
20
|
import { recall as memRecall, conform as memConform } from './memory.mjs';
|
|
21
21
|
import { PW } from './bins.mjs';
|
|
22
|
-
import { frameBlock, mandatesInForce, protectedDirty } from './contract.mjs';
|
|
22
|
+
import { frameBlock, mandatesInForce, protectedDirty, blobAt, lockState } from './contract.mjs';
|
|
23
23
|
|
|
24
24
|
const SELF = fileURLToPath(import.meta.url);
|
|
25
25
|
const HERE = dirname(SELF);
|
|
@@ -200,12 +200,12 @@ switch (cmd) {
|
|
|
200
200
|
catch (e) { process.exit(e.status || 1); }
|
|
201
201
|
break;
|
|
202
202
|
}
|
|
203
|
-
case 'context': { const o = contextBlock(
|
|
203
|
+
case 'context': { const o = contextBlock(repoRoot(), flags.has('--speak')); if (o) out(o); break; }
|
|
204
204
|
case 'frame': {
|
|
205
205
|
// the prompt face of the contract: re-impress the hard rules (mandates, resolved through `extends`)
|
|
206
206
|
// as one compact block at the live end of the context, so a deep or freshly compacted session
|
|
207
207
|
// cannot forget them. Silent without a contract; --speak explains instead.
|
|
208
|
-
const o = frameBlock(
|
|
208
|
+
const o = frameBlock(repoRoot());
|
|
209
209
|
if (o) out(o);
|
|
210
210
|
else if (flags.has('--speak')) out('🐴 sawbuck frame: no mandates in promptwheel.config.json yet. Add one (a protected path) and this re-asserts it every turn and after every compaction, so a deep context cannot forget it.');
|
|
211
211
|
break;
|
|
@@ -214,7 +214,10 @@ switch (cmd) {
|
|
|
214
214
|
// the ONE per-prompt hook: the contract frame (forward: what must hold) and the relevant history
|
|
215
215
|
// (backward: what broke here before) in a single block, one process per prompt. Silent when
|
|
216
216
|
// there is nothing to say. Wired by `attach --claude` on UserPromptSubmit and after a compaction.
|
|
217
|
-
|
|
217
|
+
// the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
|
|
218
|
+
// subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
|
|
219
|
+
const top = gitTop(repoArg());
|
|
220
|
+
const repo = top || repoArg();
|
|
218
221
|
// When a PROMPT begins (the UserPromptSubmit hook, never the SessionStart[compact] one, which
|
|
219
222
|
// fires mid-turn), remember which protected paths were already dirty and what they held, so the
|
|
220
223
|
// Stop hook's wall counts only what this turn changes and leaves a person's own edits alone.
|
|
@@ -224,33 +227,40 @@ switch (cmd) {
|
|
|
224
227
|
// this command mid-turn leaves the last one standing, so nothing the agent left behind is adopted);
|
|
225
228
|
// a file still carrying the content the last violation flagged is never baselined; and the
|
|
226
229
|
// baseline's commit does not advance past a flagged commit until a clean stop or a commit the
|
|
227
|
-
// person made between turns.
|
|
230
|
+
// person made between turns that no longer carries the flagged content.
|
|
228
231
|
let hookEvent = '';
|
|
229
232
|
if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
230
|
-
if (hookEvent === 'UserPromptSubmit') {
|
|
233
|
+
if (hookEvent === 'UserPromptSubmit' && top) {
|
|
231
234
|
try {
|
|
235
|
+
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
236
|
+
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
237
|
+
const now = new Date().toISOString();
|
|
232
238
|
if (mandatesInForce(repo).length) {
|
|
233
|
-
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
234
|
-
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
235
|
-
const now = new Date().toISOString();
|
|
236
239
|
const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
|
|
237
240
|
const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
|
|
238
241
|
const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
|
|
239
242
|
if (!unfinished) {
|
|
240
243
|
const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
|
|
241
|
-
const
|
|
242
|
-
|
|
244
|
+
const before = (st.lastViolation && st.lastViolation.before && typeof st.lastViolation.before === 'object') ? st.lastViolation.before : {};
|
|
245
|
+
// the content is stored (hash-object -w), so a person's edit the agent later overwrites can
|
|
246
|
+
// be restored by hash; a file still carrying the flagged content is baselined at the content
|
|
247
|
+
// the person had before the agent touched it, or not at all
|
|
248
|
+
const files = protectedDirty(repo, { store: true });
|
|
249
|
+
for (const f of Object.keys(files)) if (flagged[f] === files[f]) { if (before[f] && before[f] !== 'deleted') files[f] = before[f]; else delete files[f]; }
|
|
243
250
|
let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
|
|
244
251
|
// HEAD moved since the last Stop: the agent was not running, so the commits between turns
|
|
245
252
|
// are the person's (accepting a change, a pull, a branch switch) and the baseline starts
|
|
246
|
-
// there
|
|
253
|
+
// there, UNLESS HEAD still carries a flagged file's content: then the pin holds (a commit
|
|
254
|
+
// a background process landed after the Stop does not walk the agent's change through).
|
|
247
255
|
const moved = !!(sha && st.lastStopHead && sha !== st.lastStopHead);
|
|
248
|
-
|
|
249
|
-
|
|
256
|
+
const stillInHead = moved && Object.entries(flagged).some(([f, h]) => { const b = blobAt(repo, sha, f); return h === 'deleted' ? b === '' : (!!h && b === h); });
|
|
257
|
+
const pin = !!(st.lastViolation && st.lastViolation.sha);
|
|
258
|
+
if (moved && !stillInHead && st.lastViolation) delete st.lastViolation.sha;
|
|
259
|
+
st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo) };
|
|
250
260
|
}
|
|
251
|
-
st.lastPromptAt = now;
|
|
252
|
-
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
253
261
|
}
|
|
262
|
+
st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
|
|
263
|
+
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
254
264
|
} catch { /* the frame never fails a prompt */ }
|
|
255
265
|
}
|
|
256
266
|
const o = [frameBlock(repo), contextBlock(repo, false)].filter(Boolean).join('\n');
|
|
@@ -260,8 +270,8 @@ switch (cmd) {
|
|
|
260
270
|
case 'recall': {
|
|
261
271
|
// the notes face: supplementary relevant notes for your current changes, from any non-git memory
|
|
262
272
|
// provider (e.g. an Obsidian vault). On-demand. Git history + live debt come from context / debt.
|
|
263
|
-
const repo =
|
|
264
|
-
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
|
|
273
|
+
const repo = repoRoot();
|
|
274
|
+
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
265
275
|
let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
|
|
266
276
|
if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
|
|
267
277
|
if (!changed.length) { out('🐴 sawbuck: nothing changed to look up.'); break; }
|
|
@@ -434,7 +444,7 @@ function hookBlock(repo) {
|
|
|
434
444
|
// git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check
|
|
435
445
|
// shells out to git (worktrees, diffs) and would inherit them, operate on the wrong
|
|
436
446
|
// repo, and silently catch nothing. Clear them so the reflex runs against `repo` clean.
|
|
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
|
|
447
|
+
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 ${nodeCmd()} ${JSON.stringify(SELF)} __hook --repo ${r}; fi || true\n${S_END}\n`;
|
|
438
448
|
}
|
|
439
449
|
function wireGit(repo) {
|
|
440
450
|
if (!gitTop(repo)) usage(`${repo} is not a git repository`);
|
|
@@ -488,6 +498,13 @@ function claudeFile(repo, { user, shared }) {
|
|
|
488
498
|
const name = user || shared ? 'settings.json' : 'settings.local.json';
|
|
489
499
|
return { dir, f: join(dir, name) };
|
|
490
500
|
}
|
|
501
|
+
// hooks run under /bin/sh, which does not load nvm, so a bare `node` is missing on many machines and the
|
|
502
|
+
// hook fails quietly, leaving the wall off. Use node from PATH when there is one (it follows an upgrade),
|
|
503
|
+
// else the node that ran this attach. Windows keeps plain node: its hooks do not run under sh.
|
|
504
|
+
export function nodeCmd(execPath = process.execPath, platform = process.platform) {
|
|
505
|
+
if (platform === 'win32') return 'node';
|
|
506
|
+
return `"$(command -v node || echo ${JSON.stringify(execPath).replace(/^"|"$/g, "'")})"`;
|
|
507
|
+
}
|
|
491
508
|
function wireClaude(repo, opts) {
|
|
492
509
|
const { user, shared } = opts;
|
|
493
510
|
const { dir, f } = claudeFile(repo, opts);
|
|
@@ -495,8 +512,8 @@ function wireClaude(repo, opts) {
|
|
|
495
512
|
const fileExisted = existsSync(f);
|
|
496
513
|
let cfg = {};
|
|
497
514
|
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.`); } }
|
|
498
|
-
const ctx = user ?
|
|
499
|
-
const chk = user ?
|
|
515
|
+
const ctx = user ? `${nodeCmd()} ${JSON.stringify(SELF)} turn --hook` : `${nodeCmd()} ${JSON.stringify(SELF)} turn --hook ${JSON.stringify(repo)}`;
|
|
516
|
+
const chk = user ? `${nodeCmd()} ${JSON.stringify(SELF)} __hook --by claude --stop` : `${nodeCmd()} ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
|
|
500
517
|
// attached already? "already attached" only when every hook of THIS version is present; an older
|
|
501
518
|
// attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
|
|
502
519
|
let upgrading = false;
|