sawbuck 0.1.33 → 0.1.35

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/README.md CHANGED
@@ -60,6 +60,7 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
60
60
  | `sawbuck turn` | frame + context in one block: what `attach --claude` runs on every prompt and again right after a compaction |
61
61
  | `sawbuck recall` | related notes for your current change from extra memory providers you compose in (bring your own) |
62
62
  | `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
63
+ | `sawbuck contract` | one command to sign a repo: keeps your contract, adds tests and tripwires if there are none, protected paths, the supply-chain guards, and the architecture lock. `--dry-run` previews it. |
63
64
  | `sawbuck where` · `sawbuck statusline` | list every repo it is attached to; a one-line status-line segment (🐴 watching / N caught / steer %) |
64
65
 
65
66
  ## Give your agent the contract
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // sawbuck contract: one command that signs a repo to a working contract. Keeps whatever contract is already
3
+ // here, adds your tests and tripwires if there is none, then the supply-chain protections and the architecture
4
+ // lock. Zero-LLM. Re-running changes nothing. Protected product code (auth, billing) is suggested, never added:
5
+ // freezing those is a person's decision.
6
+ import { execFileSync } from 'node:child_process';
7
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, mkdirSync } from 'node:fs';
8
+ import { join, dirname, resolve } from 'node:path';
9
+ import { tmpdir } from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { PW } from './bins.mjs';
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const repo = process.argv[2] || process.cwd();
15
+ const dry = process.argv.includes('--dry-run');
16
+ const cfgPath = join(repo, 'promptwheel.config.json');
17
+ const run = (args, cwd, stdio = ['ignore', 'pipe', 'pipe']) => execFileSync(process.execPath, args, { cwd, encoding: 'utf8', stdio });
18
+
19
+ // the preset as the engine writes it, in a scratch repo, so the contract and the engine cannot drift apart
20
+ const scratch = mkdtempSync(join(tmpdir(), 'sawbuck-contract-'));
21
+ try {
22
+ execFileSync('git', ['init', '-q'], { cwd: scratch, stdio: 'ignore' });
23
+ writeFileSync(join(scratch, 'package.json'), '{"name":"scratch"}\n');
24
+ run([PW, 'init', '--preset', 'supply-chain', '--no-agent-block'], scratch);
25
+ const preset = JSON.parse(readFileSync(join(scratch, 'promptwheel.config.json'), 'utf8'));
26
+ const had = existsSync(cfgPath);
27
+ if (dry) {
28
+ process.stdout.write(`sawbuck contract would ${had ? 'add to' : 'write'} ${cfgPath}: ${preset.metrics.length} guards and ${preset.mandates.length} protected-path rules, the scanners under .promptwheel/packs/, and promptwheel.lock.json.\n`);
29
+ } else {
30
+ if (!had) run([join(HERE, 'init.mjs'), '-y', '--no-investigate'], repo);
31
+ const cfg = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, 'utf8')) : { repeat: 1, metrics: [] };
32
+ const before = { m: (cfg.mandates || []).length, g: (cfg.metrics || []).length };
33
+ const names = (l) => new Set((l || []).map((x) => x && x.name));
34
+ const hasM = names(cfg.mandates), hasG = names(cfg.metrics);
35
+ cfg.mandates = [...(cfg.mandates || []), ...preset.mandates.filter((m) => !hasM.has(m.name))];
36
+ cfg.metrics = [...(cfg.metrics || []), ...preset.metrics.filter((m) => !hasG.has(m.name))];
37
+ cfg.evaluatorPaths = [...new Set([...(cfg.evaluatorPaths || []), ...(preset.evaluatorPaths || [])])];
38
+ cfg.allowTrailer = false;
39
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
40
+ const pack = join(repo, '.promptwheel', 'packs', 'supply-chain');
41
+ mkdirSync(pack, { recursive: true });
42
+ cpSync(join(scratch, '.promptwheel', 'packs', 'supply-chain'), pack, { recursive: true });
43
+ // the gate measures in clean worktrees, so the scanners must reach git. A repo attached earlier, or one that
44
+ // ignores .promptwheel/ for the outcome record, would hide them and every guard would read unmeasurable.
45
+ for (const f of [join(repo, '.gitignore'), resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8' }).trim())]) {
46
+ if (!existsSync(f)) continue;
47
+ const text = readFileSync(f, 'utf8');
48
+ const next = text.replace(/^\.promptwheel\/?[ \t]*$/gm, '.promptwheel/*\n!.promptwheel/packs/');
49
+ if (next !== text) writeFileSync(f, next);
50
+ }
51
+ const locked = existsSync(join(repo, 'promptwheel.lock.json'));
52
+ if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
53
+ const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
54
+ process.stdout.write(added || !locked ? `sawbuck: signed. ${cfg.metrics.length} guards, ${cfg.mandates.length} protected-path rules, and the shape of this repo in promptwheel.lock.json.\n` : 'sawbuck: already signed, nothing changed.\n');
55
+ }
56
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
57
+
58
+ // a suggestion, not a rule: only a person should freeze product code
59
+ let files = [];
60
+ try { files = execFileSync('git', ['-C', repo, 'ls-files'], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }).split('\n'); } catch { /* not a repo: attach says so */ }
61
+ const seen = new Set();
62
+ for (const f of files) { const m = f.match(/^(.*?(?:^|\/)(?:auth|authentication|billing|payments?|migrations))\//); if (m && !/(^|\/)(node_modules|test|tests|docs?)\//.test(m[1])) seen.add(m[1]); }
63
+ if (seen.size) process.stdout.write(`\n To protect product code too, add these to "mandates" in promptwheel.config.json:\n${[...seen].slice(0, 5).map((d) => ` { "name": "${d.replace(/\W+/g, '-')}", "paths": ["${d}/**"], "reason": "changed by a person" }`).join('\n')}\n`);
64
+ if (!dry) process.stdout.write('\n Commit promptwheel.config.json, promptwheel.lock.json and .promptwheel/packs/. Then /sawbuck-reins:watch or sawbuck attach --claude arms it.\n');
package/contract.mjs CHANGED
@@ -99,7 +99,7 @@ export function globToRe(glob) {
99
99
  // which changed paths violate which mandate: [{ name, reason, files, committed? }]. The rules are the
100
100
  // working tree's plus the committed ones (mandatesInForce); pass { ref: null } for the working tree alone.
101
101
  export function mandateHits(repo, changedPaths, opts = {}) {
102
- const list = opts.ref === null ? resolvedMandates(repo) : mandatesInForce(repo, opts.ref || 'HEAD');
102
+ const list = opts.list || (opts.ref === null ? resolvedMandates(repo) : mandatesInForce(repo, opts.ref || 'HEAD'));
103
103
  const out = [];
104
104
  for (const m of list) {
105
105
  const res = pathsOf(m).map(globToRe);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.33",
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.",
3
+ "version": "0.1.35",
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, git hooks, and CI, with Codex support in beta.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "engines": {
@@ -34,10 +34,11 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@promptwheel/logbook": "0.9.10",
37
- "promptwheel": "0.7.11"
37
+ "promptwheel": "0.7.12"
38
38
  },
39
39
  "files": [
40
40
  "sawbuck.mjs",
41
+ "contract-cmd.mjs",
41
42
  "bins.mjs",
42
43
  "contract.mjs",
43
44
  "init.mjs",
package/sawbuck-hook.mjs CHANGED
@@ -89,7 +89,9 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
89
89
  const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
90
90
  const changed = [...new Set([...dirty.filter((f) => !untouched(f)), ...committedSince])];
91
91
  const leftAlone = dirty.filter(untouched);
92
- const hits = changed.length ? mandateHits(repo, changed) : [];
92
+ // the rules in force when the turn began: a rule this turn adds does not apply to it, so signing a contract does
93
+ // not block itself, and one it deletes or narrows still holds
94
+ const hits = changed.length ? mandateHits(repo, changed, base && Array.isArray(base.rules) ? { list: base.rules } : {}) : [];
93
95
  st.lastStopAt = now; // the next prompt may take a fresh baseline only because this stop happened
94
96
  st.lastStopHead = head || null; // and where HEAD stood: a commit between turns is the person's, not the agent's
95
97
  if (hits.length) {
@@ -197,7 +199,7 @@ function ensureExcluded(r) {
197
199
  const f = resolve(r, rel);
198
200
  // the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
199
201
  // the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
200
- const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n# <<< sawbuck <<<\n';
202
+ const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/*\n!.promptwheel/packs/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n# <<< sawbuck <<<\n';
201
203
  const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
202
204
  const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
203
205
  const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
package/sawbuck.mjs CHANGED
@@ -43,7 +43,7 @@ const BLOCK_RE = new RegExp(`\\n?${S_START}[\\s\\S]*?${S_END}\\n?`, 'g');
43
43
  // plus the logbook's root working-set (ledger cache, digests, annotation planes). It is
44
44
  // memory, not the user's source, a user who wants LOGBOOK.md committed still can explicitly
45
45
  // (exclude only affects UNtracked files).
46
- const RECORD_DIRS = ['.sawbuck/', '.promptwheel/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
46
+ const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
47
47
 
48
48
  const argv = process.argv.slice(2);
49
49
  const cmd = argv[0];
@@ -90,6 +90,7 @@ Usage:
90
90
  sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
91
91
  sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
92
92
  sawbuck where List every repo Sawbuck is attached to
93
+ sawbuck contract Sign this repo: tests, tripwires, protected paths, and the architecture lock (--dry-run to preview)
93
94
  sawbuck statusline One-line segment for your status line (🐴 watching | N caught | steer %)
94
95
 
95
96
  sawbuck recall [repo] Related notes for your changes from extra memory providers
@@ -116,6 +117,13 @@ if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0)
116
117
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { out(help); process.exit(0); }
117
118
 
118
119
  switch (cmd) {
120
+ case 'contract': {
121
+ const top = gitTop(repoArg());
122
+ if (!top) usage(`${repoArg()} is not a git repository (a contract signs one; run it inside your project)`);
123
+ try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => a === '--dry-run')], { stdio: 'inherit' }); }
124
+ catch (e) { process.exit(e.status || 1); }
125
+ break;
126
+ }
119
127
  case 'init': {
120
128
  try { execFileSync(process.execPath, [join(HERE, 'init.mjs'), ...rest], { stdio: 'inherit' }); }
121
129
  catch (e) { process.exit(e.status || 1); }
@@ -235,7 +243,7 @@ switch (cmd) {
235
243
  const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
236
244
  let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
237
245
  const now = new Date().toISOString();
238
- if (mandatesInForce(repo).length) {
246
+ { // a baseline even with no rules yet: a turn that signs a contract is judged by the contract it began with
239
247
  const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
240
248
  const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
241
249
  const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
@@ -256,7 +264,7 @@ switch (cmd) {
256
264
  const stillInHead = moved && Object.entries(flagged).some(([f, h]) => { const b = blobAt(repo, sha, f); return h === 'deleted' ? b === '' : (!!h && b === h); });
257
265
  const pin = !!(st.lastViolation && st.lastViolation.sha);
258
266
  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) };
267
+ st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo), rules: mandatesInForce(repo) };
260
268
  }
261
269
  }
262
270
  st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
@@ -634,7 +642,8 @@ function onPath() {
634
642
  if (process.env.SAWBUCK_NO_PATH_CHECK) return false;
635
643
  try { execFileSync('sh', ['-c', 'command -v sawbuck'], { stdio: 'ignore' }); return true; } catch { return false; }
636
644
  }
637
- function extractBaked(text) { const m = text.match(/node "([^"]+)"/); return m ? m[1] : null; }
645
+ // 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
646
+ function extractBaked(text) { const m = text.match(/"([^"]*sawbuck\.mjs)\\?"/); return m ? m[1] : null; }
638
647
  function doctorRepo(repo) {
639
648
  const checks = [];
640
649
  const pc = gitPath(repo, 'hooks/post-commit');