sawbuck 0.1.32 → 0.1.34
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.mjs +11 -1
- package/package.json +3 -3
- package/reflect.mjs +4 -1
- package/sawbuck-hook.mjs +20 -1
- package/sawbuck.mjs +14 -6
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
|
|
|
@@ -124,3 +124,13 @@ export function frameBlock(repo) {
|
|
|
124
124
|
const lines = mandates.map((m) => ` ${m.paths.filter((s) => typeof s === 'string' && s.trim()).join(' · ')}${m.reason ? ` (${String(m.reason).slice(0, 160)})` : ''}`);
|
|
125
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');
|
|
126
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.34",
|
|
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
|
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
|
|
@@ -156,6 +156,25 @@ if (stop && gamedThisTurn && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
156
156
|
process.exit(0);
|
|
157
157
|
}
|
|
158
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
|
+
|
|
159
178
|
// 4) otherwise, surface a catch on a fresh catch or on cadence (a flag; never blocks)
|
|
160
179
|
const freshCatch = total > (st.shownTotal || 0);
|
|
161
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, blobAt } 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);
|
|
@@ -256,7 +256,7 @@ switch (cmd) {
|
|
|
256
256
|
const stillInHead = moved && Object.entries(flagged).some(([f, h]) => { const b = blobAt(repo, sha, f); return h === 'deleted' ? b === '' : (!!h && b === h); });
|
|
257
257
|
const pin = !!(st.lastViolation && st.lastViolation.sha);
|
|
258
258
|
if (moved && !stillInHead && st.lastViolation) delete st.lastViolation.sha;
|
|
259
|
-
st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files };
|
|
259
|
+
st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo) };
|
|
260
260
|
}
|
|
261
261
|
}
|
|
262
262
|
st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
|
|
@@ -444,7 +444,7 @@ function hookBlock(repo) {
|
|
|
444
444
|
// git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check
|
|
445
445
|
// shells out to git (worktrees, diffs) and would inherit them, operate on the wrong
|
|
446
446
|
// repo, and silently catch nothing. Clear them so the reflex runs against `repo` clean.
|
|
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
|
|
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`;
|
|
448
448
|
}
|
|
449
449
|
function wireGit(repo) {
|
|
450
450
|
if (!gitTop(repo)) usage(`${repo} is not a git repository`);
|
|
@@ -498,6 +498,13 @@ function claudeFile(repo, { user, shared }) {
|
|
|
498
498
|
const name = user || shared ? 'settings.json' : 'settings.local.json';
|
|
499
499
|
return { dir, f: join(dir, name) };
|
|
500
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
|
+
}
|
|
501
508
|
function wireClaude(repo, opts) {
|
|
502
509
|
const { user, shared } = opts;
|
|
503
510
|
const { dir, f } = claudeFile(repo, opts);
|
|
@@ -505,8 +512,8 @@ function wireClaude(repo, opts) {
|
|
|
505
512
|
const fileExisted = existsSync(f);
|
|
506
513
|
let cfg = {};
|
|
507
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.`); } }
|
|
508
|
-
const ctx = user ?
|
|
509
|
-
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`;
|
|
510
517
|
// attached already? "already attached" only when every hook of THIS version is present; an older
|
|
511
518
|
// attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
|
|
512
519
|
let upgrading = false;
|
|
@@ -627,7 +634,8 @@ function onPath() {
|
|
|
627
634
|
if (process.env.SAWBUCK_NO_PATH_CHECK) return false;
|
|
628
635
|
try { execFileSync('sh', ['-c', 'command -v sawbuck'], { stdio: 'ignore' }); return true; } catch { return false; }
|
|
629
636
|
}
|
|
630
|
-
|
|
637
|
+
// the path a hook bakes in: the quoted one ending in sawbuck.mjs, in a shell hook or in JSON, where the quotes are escaped
|
|
638
|
+
function extractBaked(text) { const m = text.match(/"([^"]*sawbuck\.mjs)\\?"/); return m ? m[1] : null; }
|
|
631
639
|
function doctorRepo(repo) {
|
|
632
640
|
const checks = [];
|
|
633
641
|
const pc = gitPath(repo, 'hooks/post-commit');
|