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 +1 -0
- package/contract-cmd.mjs +64 -0
- package/contract.mjs +1 -1
- package/package.json +4 -3
- package/sawbuck-hook.mjs +4 -2
- package/sawbuck.mjs +13 -4
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
|
package/contract-cmd.mjs
ADDED
|
@@ -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.
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|