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 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.32",
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": "^0.9.6",
37
- "promptwheel": "^0.7.9"
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
- const runNode = (file, a) => execFileSync(process.execPath, [file, ...a], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: CHECK_TIMEOUT, killSignal: 'SIGKILL' });
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 node ${JSON.stringify(SELF)} __hook --repo ${r}; fi || true\n${S_END}\n`;
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 ? `node ${JSON.stringify(SELF)} turn --hook` : `node ${JSON.stringify(SELF)} turn --hook ${JSON.stringify(repo)}`;
509
- const chk = user ? `node ${JSON.stringify(SELF)} __hook --by claude --stop` : `node ${JSON.stringify(SELF)} __hook --repo ${JSON.stringify(repo)} --by claude --stop`;
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
- function extractBaked(text) { const m = text.match(/node "([^"]+)"/); return m ? m[1] : null; }
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');