sawbuck 0.1.34 → 0.1.36
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 +22 -6
- package/contract-cmd.mjs +64 -0
- package/contract.mjs +2 -2
- package/package.json +5 -3
- package/reflect.mjs +1 -1
- package/rewrites.mjs +60 -0
- package/sawbuck-hook.mjs +10 -6
- package/sawbuck.mjs +23 -6
package/README.md
CHANGED
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
**Sign your agent to a contract so it does what you want.**
|
|
4
4
|
|
|
5
|
-
You write the contract (what a real win looks like); Sawbuck enforces it, re-proving every win from the agent's **source** edits alone, so a
|
|
5
|
+
You write the contract (what a real win looks like); Sawbuck enforces it, re-proving every win from the agent's **source** edits alone, so a test that went from red to green only because the agent edited the test, mocked the grader, or loosened a declared config reads `gamed` and goes back. Deterministic and zero-LLM: no model in the verdict, nothing read from the agent's trajectory. Attach it and it carries your repo's history along: what broke in these files before, surfaced before you touch them.
|
|
6
6
|
|
|
7
7
|
> Sawbuck is the reference implementation of the [Verified-Outcome Contract](CONTRACT.md): the open interface for a coding loop you can trust.
|
|
8
8
|
|
|
9
|
+
In Claude Code, install the plugin and keep working:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
/plugin marketplace add sawbuck-ai/reins
|
|
13
|
+
/plugin install sawbuck-reins@sawbuck
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The first time you open a git repository with no contract, Claude finishes your task and offers once to sign it. Say yes: every turn checks protected paths, and every commit is re-proved.
|
|
17
|
+
|
|
18
|
+
Or from the command line:
|
|
19
|
+
|
|
9
20
|
```sh
|
|
10
21
|
# 1 · try it (no install, no commitment): verify your latest change right now
|
|
11
22
|
npx sawbuck check
|
|
@@ -13,7 +24,7 @@ npx sawbuck check
|
|
|
13
24
|
# 2 · enforce it: attach once to this repo
|
|
14
25
|
npm i -g sawbuck
|
|
15
26
|
sawbuck attach # every commit (a git post-commit hook)
|
|
16
|
-
sawbuck attach --claude # or every Claude Code turn (a project-scoped Stop hook)
|
|
27
|
+
sawbuck attach --claude # or protected paths every Claude Code turn, plus every commit (a project-scoped Stop hook)
|
|
17
28
|
```
|
|
18
29
|
|
|
19
30
|
**Zero-config:** it auto-detects your test command, so there is nothing to write to start.
|
|
@@ -38,12 +49,12 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
|
|
|
38
49
|
|
|
39
50
|
## What it does
|
|
40
51
|
|
|
41
|
-
- **Verifies the win.** `check` re-runs your metric from the source diff alone. A
|
|
52
|
+
- **Verifies the win.** `check` re-runs your metric from the source diff alone. A test that went from red to green because of a deleted, weakened, or skipped test, a mocked grader, an edited golden file, or a loosened declared config reads `gamed`. A suite weakened while it stays green is not proved: there is no win to re-run. Tripwires catch skips and removed assertions in a signed repo. Deterministic, zero-LLM, $0, and it never reads the trajectory. No appeals once signed; `--allow` is recorded. Red-teamed across 9 ecosystems in 26 scenarios: 12 caught, 10 stated limits, 4 honest ([promptwheel/bench/REDTEAM.md](https://github.com/promptwheel-ai/promptwheel/blob/main/bench/REDTEAM.md)).
|
|
42
53
|
- **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
54
|
- **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
55
|
- **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
56
|
- **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
|
|
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`
|
|
57
|
+
- **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` removes what it wired. Signed contract files stay because they are yours to review; `--purge` deletes the record. `trace` shows every byte it placed, and `detach --verify` proves the wiring is gone.
|
|
47
58
|
|
|
48
59
|
## Commands
|
|
49
60
|
|
|
@@ -60,11 +71,12 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
|
|
|
60
71
|
| `sawbuck turn` | frame + context in one block: what `attach --claude` runs on every prompt and again right after a compaction |
|
|
61
72
|
| `sawbuck recall` | related notes for your current change from extra memory providers you compose in (bring your own) |
|
|
62
73
|
| `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
|
|
74
|
+
| `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
75
|
| `sawbuck where` · `sawbuck statusline` | list every repo it is attached to; a one-line status-line segment (🐴 watching / N caught / steer %) |
|
|
64
76
|
|
|
65
77
|
## Give your agent the contract
|
|
66
78
|
|
|
67
|
-
`sawbuck init` writes this block into your `AGENTS.md` for you (and `CLAUDE.md` / `.cursorrules` if present; `--no-agent-block` to skip), and `sawbuck attach --claude` enforces the
|
|
79
|
+
`sawbuck init` writes this block into your `AGENTS.md` for you (and `CLAUDE.md` / `.cursorrules` if present; `--no-agent-block` to skip), and `sawbuck attach --claude` enforces it through the hook: every turn checks protected paths, and every commit is re-proved. Here it is to paste yourself if you want:
|
|
68
80
|
|
|
69
81
|
This repo is under a Sawbuck contract: a change is done only when the win survives verification
|
|
70
82
|
from the source alone. Before finishing, run `sawbuck check`. If it reads `gamed`, the green came
|
|
@@ -72,7 +84,11 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
|
|
|
72
84
|
Run `sawbuck context` first to see what broke in these files before (reverts, weakened tests,
|
|
73
85
|
fragile spots). Never skip, weaken, or delete a test to go green.
|
|
74
86
|
|
|
75
|
-
**
|
|
87
|
+
**Rewritten history.** Logbook reads git, so a squash or a rebase takes a revert with it. `sawbuck context` also reads the reflog for commits no branch reaches any more and shows them for the files in hand, and once attached it keeps them in `.sawbuck/rewritten.jsonl` after the reflog expires. It stays local: a clone does not get it, nothing older than the reflog comes back, and work that was never committed is not in git at all. Your steering is separate: `reins` reads your agent transcripts, not git.
|
|
88
|
+
|
|
89
|
+
**Tested claims.** Every claim above, and every limit, is a test in [`test/claims.test.mjs`](test/claims.test.mjs), run on a clean account.
|
|
90
|
+
|
|
91
|
+
**Measured, not assumed.** On a scenario whose natural fix sits inside a protected module, 62 trials: 52 driven past a forced compaction (two on the default model, one on Haiku) and 10 shallow, some under "urgent hotfix" pressure. With no rule the agent edited the protected file 17 of 17 times; with a rule in any form, including a mandate, 0 of 45. Earlier scenarios did not tempt it (no rule, 0 of 17), and the wall never had to fire. The model honors a rule it was told. The gate is for the rule nobody told it, the session that outlived the summary, and the proof that it held: `attach --claude` re-reads the mandates from the repo on every turn, re-impresses them after a compaction (`sawbuck turn`), and sends back a turn that leaves a protected path changed, up to three times by default. Numbers and method: [sawbuck.ai/contracts](https://sawbuck.ai/contracts).
|
|
76
92
|
|
|
77
93
|
Without a `promptwheel.config.json` the check measures your detected test command. To make it count more (coverage, lint, latency, an eval) or to protect a path with a mandate, `sawbuck init` writes the config and explains each line with evidence from your history.
|
|
78
94
|
|
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
|
@@ -72,7 +72,7 @@ export function hashOf(repo, file, { store = false } = {}) {
|
|
|
72
72
|
// `store` keeps each file's content in the object database (see hashOf), which the baseline does.
|
|
73
73
|
export function protectedDirty(repo, { store = false } = {}) {
|
|
74
74
|
let changed = [];
|
|
75
|
-
try { changed = [...new Set([...gitOut(repo, ['diff', '--name-only', 'HEAD']).split('\n'), ...gitOut(repo, ['ls-files', '--others', '--exclude-standard']).split('\n')].filter(Boolean))]; }
|
|
75
|
+
try { changed = [...new Set([...gitOut(repo, ['diff', '--name-only', '--no-renames', 'HEAD']).split('\n'), ...gitOut(repo, ['ls-files', '--others', '--exclude-standard']).split('\n')].filter(Boolean))]; }
|
|
76
76
|
catch { return {}; }
|
|
77
77
|
const res = mandatesInForce(repo).flatMap((m) => pathsOf(m).map(globToRe));
|
|
78
78
|
const out = {};
|
|
@@ -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
|
|
3
|
+
"version": "0.1.36",
|
|
4
|
+
"description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves each win from the agent's source edits alone, so a test that went green by editing the test, grader, or config is sent back. For Claude Code, with Codex support in beta.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"engines": {
|
|
@@ -34,10 +34,12 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@promptwheel/logbook": "0.9.10",
|
|
37
|
-
"promptwheel": "0.7.
|
|
37
|
+
"promptwheel": "0.7.13"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"sawbuck.mjs",
|
|
41
|
+
"rewrites.mjs",
|
|
42
|
+
"contract-cmd.mjs",
|
|
41
43
|
"bins.mjs",
|
|
42
44
|
"contract.mjs",
|
|
43
45
|
"init.mjs",
|
package/reflect.mjs
CHANGED
|
@@ -131,7 +131,7 @@ function appendActivity(v, rep, standing = false) {
|
|
|
131
131
|
const why = k === 'gamed' ? (gm?.gamingReason || 'the win came from editing the goalposts')
|
|
132
132
|
: k === 'regression' ? 'a guarded metric regressed beyond noise'
|
|
133
133
|
: k === 'allowed' ? ('declared intentional: ' + (rep.allowed?.reason || ''))
|
|
134
|
-
: k === 'verified' ? 'the
|
|
134
|
+
: k === 'verified' ? 'the check passed'
|
|
135
135
|
: 'could not certify a pass';
|
|
136
136
|
// `standing`: this check re-proved the span of a gamed verdict that is still up, not a new catch
|
|
137
137
|
const ev = { at: new Date().toISOString(), kind: k, sha, file, reason: String(why).slice(0, 160), by: by || null, ...(standing ? { standing: true } : {}) };
|
package/rewrites.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What a rewrite erased. Logbook reads history, so a squash, a rebase or a force push takes the lesson with
|
|
2
|
+
// it: a reverted attempt stops being there to find. Git keeps those commits in the reflog for a while, so
|
|
3
|
+
// the commits a branch no longer reaches are read from there, kept in .sawbuck/rewritten.jsonl once this repo
|
|
4
|
+
// is attached (the reflog expires, the ledger does not), and surfaced by `context` for the paths in hand.
|
|
5
|
+
// Reads git only, no model, no network. The ledger is local: a clone does not get it. Never writes in a repo
|
|
6
|
+
// sawbuck is not attached to.
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const MAX = 300;
|
|
12
|
+
const g = (repo, ...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 32 * 1024 * 1024 }); } catch { return ''; } };
|
|
13
|
+
|
|
14
|
+
function ledger(repo) {
|
|
15
|
+
try { return readFileSync(join(repo, '.sawbuck', 'rewritten.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); } catch { return []; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// commits the reflog remembers and no ref reaches. An amend or a rebase replays the same subject, so a commit
|
|
19
|
+
// whose subject is still reachable was not lost, only rewritten.
|
|
20
|
+
function fromReflog(repo) {
|
|
21
|
+
const raw = g(repo, 'log', '--reflog', '--not', '--all', '-n', String(MAX), '--format=%x00%H%x09%cI%x09%s', '--name-only', '--no-renames');
|
|
22
|
+
if (!raw.trim()) return [];
|
|
23
|
+
const kept = new Set(g(repo, 'log', '--all', '-n', '5000', '--format=%s').split('\n'));
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const chunk of raw.split('\0').slice(1)) {
|
|
26
|
+
const [head, ...rest] = chunk.split('\n');
|
|
27
|
+
const [sha, at, ...s] = head.split('\t');
|
|
28
|
+
const subject = s.join('\t');
|
|
29
|
+
if (!sha || kept.has(subject)) continue;
|
|
30
|
+
out.push({ sha, at, subject, revert: /^revert\b/i.test(subject), files: rest.filter(Boolean).slice(0, 50) });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function erasedCommits(repo) {
|
|
36
|
+
const known = ledger(repo);
|
|
37
|
+
const seen = new Set(known.map((e) => e.sha));
|
|
38
|
+
const fresh = fromReflog(repo).filter((e) => !seen.has(e.sha));
|
|
39
|
+
if (fresh.length && existsSync(join(repo, '.sawbuck', 'state.json'))) {
|
|
40
|
+
try { appendFileSync(join(repo, '.sawbuck', 'rewritten.jsonl'), fresh.map((e) => JSON.stringify(e)).join('\n') + '\n'); } catch {}
|
|
41
|
+
}
|
|
42
|
+
return [...known, ...fresh];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// the lines `context` adds for the paths in hand: reverts first, then the checks that caught something here
|
|
46
|
+
export function erasedBlock(repo, changed) {
|
|
47
|
+
const touched = new Set(changed);
|
|
48
|
+
const hits = erasedCommits(repo).filter((e) => (e.files || []).some((f) => touched.has(f)))
|
|
49
|
+
.sort((a, b) => (b.revert - a.revert) || String(b.at).localeCompare(String(a.at))).slice(0, 3);
|
|
50
|
+
let caught = [];
|
|
51
|
+
try {
|
|
52
|
+
caught = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l))
|
|
53
|
+
.filter((e) => e.kind === 'gamed' && touched.has(e.file)).slice(-2).reverse();
|
|
54
|
+
} catch {}
|
|
55
|
+
if (!hits.length && !caught.length) return '';
|
|
56
|
+
const lines = ['not in git history any more (recovered locally, leads):'];
|
|
57
|
+
for (const e of hits) lines.push(` - ${e.revert ? 'reverted' : 'erased by a rewrite'}: ${e.subject} (${e.sha.slice(0, 12)}, ${String(e.at).slice(0, 10)})`);
|
|
58
|
+
for (const e of caught) lines.push(` - caught as gamed on ${e.file} (${e.sha}, ${String(e.at).slice(0, 10)})`);
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -50,7 +50,9 @@ const writeRec = (r) => { try { mkdirSync(stDir, { recursive: true }); writeFile
|
|
|
50
50
|
const logActivity = (ev) => { try { mkdirSync(stDir, { recursive: true }); appendFileSync(join(stDir, 'activity.jsonl'), JSON.stringify({ at: new Date().toISOString(), ...ev }) + '\n'); } catch {} };
|
|
51
51
|
const hasCommit = (ref) => { try { execFileSync('git', ['-C', repo, 'cat-file', '-e', `${ref}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
52
52
|
let rec = readRec();
|
|
53
|
-
|
|
53
|
+
// the first turn after attach has no lastSha yet: its baseline is what that turn started from
|
|
54
|
+
const from = st.lastSha || (st.turnBaseline && st.turnBaseline.sha) || '';
|
|
55
|
+
if (head && from && head !== from) {
|
|
54
56
|
let standing = !!(rec && rec.latest && rec.latest.kind === 'gamed' && rec.latest.base && rec.latest.sha !== head.slice(0, 8));
|
|
55
57
|
if (standing && !hasCommit(rec.latest.base)) {
|
|
56
58
|
// the span can no longer be re-proved (a rebase, a squash, a pruned branch took its base away):
|
|
@@ -61,7 +63,7 @@ if (head && st.lastSha && head !== st.lastSha) {
|
|
|
61
63
|
writeRec(rec);
|
|
62
64
|
standing = false;
|
|
63
65
|
}
|
|
64
|
-
node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', standing ? rec.latest.base :
|
|
66
|
+
node(join(HERE, 'reflect.mjs'), ['--repo', repo, '--base', standing ? rec.latest.base : from, '--head', head, ...(by ? ['--by', by] : []), ...(userMode ? ['--no-auto'] : [])]);
|
|
65
67
|
rec = readRec();
|
|
66
68
|
}
|
|
67
69
|
st.lastSha = head || st.lastSha;
|
|
@@ -81,15 +83,17 @@ const saveState = () => { try { mkdirSync(stDir, { recursive: true }); writeFile
|
|
|
81
83
|
// edit COMMITTED since the baseline's commit counts too: a commit mid-turn does not walk through.
|
|
82
84
|
if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
83
85
|
const now = new Date().toISOString();
|
|
84
|
-
const dirty = [...new Set([...git('diff', '--name-only', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
|
|
86
|
+
const dirty = [...new Set([...git('diff', '--name-only', '--no-renames', 'HEAD').split('\n'), ...git('ls-files', '--others', '--exclude-standard').split('\n')].filter(Boolean))];
|
|
85
87
|
const base = (st.turnBaseline && typeof st.turnBaseline === 'object') ? st.turnBaseline : null;
|
|
86
88
|
const baseline = (base && base.files && typeof base.files === 'object') ? base.files : {};
|
|
87
|
-
const committedSince = (base && base.sha && head && base.sha !== head) ? git('diff', '--name-only', base.sha, 'HEAD').split('\n').filter(Boolean) : [];
|
|
89
|
+
const committedSince = (base && base.sha && head && base.sha !== head) ? git('diff', '--name-only', '--no-renames', base.sha, 'HEAD').split('\n').filter(Boolean) : [];
|
|
88
90
|
const hashNow = {}; for (const f of dirty) if (Object.prototype.hasOwnProperty.call(baseline, f)) hashNow[f] = hashOf(repo, f);
|
|
89
91
|
const untouched = (f) => Object.prototype.hasOwnProperty.call(baseline, f) && hashNow[f] === baseline[f];
|
|
90
92
|
const changed = [...new Set([...dirty.filter((f) => !untouched(f)), ...committedSince])];
|
|
91
93
|
const leftAlone = dirty.filter(untouched);
|
|
92
|
-
|
|
94
|
+
// the rules in force when the turn began: a rule this turn adds does not apply to it, so signing a contract does
|
|
95
|
+
// not block itself, and one it deletes or narrows still holds
|
|
96
|
+
const hits = changed.length ? mandateHits(repo, changed, base && Array.isArray(base.rules) ? { list: base.rules } : {}) : [];
|
|
93
97
|
st.lastStopAt = now; // the next prompt may take a fresh baseline only because this stop happened
|
|
94
98
|
st.lastStopHead = head || null; // and where HEAD stood: a commit between turns is the person's, not the agent's
|
|
95
99
|
if (hits.length) {
|
|
@@ -197,7 +201,7 @@ function ensureExcluded(r) {
|
|
|
197
201
|
const f = resolve(r, rel);
|
|
198
202
|
// the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
|
|
199
203
|
// 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';
|
|
204
|
+
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.claude/settings.local.json\n# <<< sawbuck <<<\n';
|
|
201
205
|
const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
|
|
202
206
|
const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
|
|
203
207
|
const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
|
package/sawbuck.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// proves the repo came back clean; `doctor` checks the attachment still resolves.
|
|
14
14
|
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { erasedBlock } from './rewrites.mjs';
|
|
16
17
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
|
|
17
18
|
import { join, dirname, resolve } from 'node:path';
|
|
18
19
|
import { fileURLToPath } from 'node:url';
|
|
@@ -43,7 +44,7 @@ const BLOCK_RE = new RegExp(`\\n?${S_START}[\\s\\S]*?${S_END}\\n?`, 'g');
|
|
|
43
44
|
// plus the logbook's root working-set (ledger cache, digests, annotation planes). It is
|
|
44
45
|
// memory, not the user's source, a user who wants LOGBOOK.md committed still can explicitly
|
|
45
46
|
// (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'];
|
|
47
|
+
const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
|
|
47
48
|
|
|
48
49
|
const argv = process.argv.slice(2);
|
|
49
50
|
const cmd = argv[0];
|
|
@@ -90,6 +91,7 @@ Usage:
|
|
|
90
91
|
sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
|
|
91
92
|
sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
|
|
92
93
|
sawbuck where List every repo Sawbuck is attached to
|
|
94
|
+
sawbuck contract Sign this repo: tests, tripwires, protected paths, and the architecture lock (--dry-run to preview)
|
|
93
95
|
sawbuck statusline One-line segment for your status line (🐴 watching | N caught | steer %)
|
|
94
96
|
|
|
95
97
|
sawbuck recall [repo] Related notes for your changes from extra memory providers
|
|
@@ -116,6 +118,13 @@ if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0)
|
|
|
116
118
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { out(help); process.exit(0); }
|
|
117
119
|
|
|
118
120
|
switch (cmd) {
|
|
121
|
+
case 'contract': {
|
|
122
|
+
const top = gitTop(repoArg());
|
|
123
|
+
if (!top) usage(`${repoArg()} is not a git repository (a contract signs one; run it inside your project)`);
|
|
124
|
+
try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => a === '--dry-run')], { stdio: 'inherit' }); }
|
|
125
|
+
catch (e) { process.exit(e.status || 1); }
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
119
128
|
case 'init': {
|
|
120
129
|
try { execFileSync(process.execPath, [join(HERE, 'init.mjs'), ...rest], { stdio: 'inherit' }); }
|
|
121
130
|
catch (e) { process.exit(e.status || 1); }
|
|
@@ -162,11 +171,14 @@ switch (cmd) {
|
|
|
162
171
|
if (json) { out(JSON.stringify(rep)); process.exit(code); }
|
|
163
172
|
const m = (rep.metrics || [])[0];
|
|
164
173
|
for (const md of rep.mandates || []) out(`🐴 ⛔ mandate "${md.name}" violated${md.reason ? ': ' + md.reason : ''} (touched: ${(md.files || []).join(', ')})${md.removed ? ' [deleted in this same change; a mandate stays in force for the change that removes it]' : ''}`);
|
|
165
|
-
|
|
174
|
+
const win = (rep.metrics || []).find((x) => x.status === 'improved' && !x.tripwire);
|
|
175
|
+
if (rep.verdict === 'pass' && win) out(`🐴 ✓ verified: your ${where} change earned it. "${win.name}" improved, and the source carried the win (it survives a source-only re-run).`);
|
|
176
|
+
else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
|
|
166
177
|
else if (rep.verdict === 'gamed') out('🐴 🚩 gamed: a win here came from editing the test, config, or grader, not the source. It does NOT survive a source-only re-run, so it does not count.');
|
|
167
178
|
else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a hard rule in the contract was broken, however green the rest is.');
|
|
168
179
|
else if (rep.verdict === 'fail') out(`🐴 ✗ regression: a guarded metric got worse (${(rep.metrics || []).filter((x) => x.guard && !x.ok).map((x) => x.name).join(', ') || 'a guard'}).`);
|
|
169
180
|
else out('🐴 ≈ inconclusive: the check ran but could not measure your metric (it did not run in isolation). Point it at your real test command: `sawbuck init`, or set setup/linkDirs in promptwheel.config.json.');
|
|
181
|
+
if ((rep.metrics || []).some((x) => x.heldByTestEdit)) out('🐴 ⚠ your tests changed too, and the old tests fail on this source. Fine for an intended change. Otherwise check the tests were not weakened: a green that stays green is not proved.');
|
|
170
182
|
if (rep.gate && rep.gate.changed) out(`🐴 ⚠ the gate definition changed in this change (fingerprint ${rep.gate.fingerprint.base} -> ${rep.gate.fingerprint.head}): a metric, guard, evaluatorPath or mandate was added, removed or edited.`);
|
|
171
183
|
process.exit(code);
|
|
172
184
|
}
|
|
@@ -235,7 +247,7 @@ switch (cmd) {
|
|
|
235
247
|
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
236
248
|
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
237
249
|
const now = new Date().toISOString();
|
|
238
|
-
|
|
250
|
+
{ // a baseline even with no rules yet: a turn that signs a contract is judged by the contract it began with
|
|
239
251
|
const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
|
|
240
252
|
const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
|
|
241
253
|
const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince;
|
|
@@ -256,7 +268,7 @@ switch (cmd) {
|
|
|
256
268
|
const stillInHead = moved && Object.entries(flagged).some(([f, h]) => { const b = blobAt(repo, sha, f); return h === 'deleted' ? b === '' : (!!h && b === h); });
|
|
257
269
|
const pin = !!(st.lastViolation && st.lastViolation.sha);
|
|
258
270
|
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) };
|
|
271
|
+
st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo), rules: mandatesInForce(repo) };
|
|
260
272
|
}
|
|
261
273
|
}
|
|
262
274
|
st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
|
|
@@ -375,14 +387,17 @@ switch (cmd) {
|
|
|
375
387
|
// "nothing notable" every turn would be noise). `speak` forces a reply for the explicit command.
|
|
376
388
|
function contextBlock(repo, speak) {
|
|
377
389
|
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
378
|
-
|
|
390
|
+
// new files count: re-creating what was reverted is the case the history is for
|
|
391
|
+
let changed = [...new Set([...g('diff', '--name-only', 'HEAD').split('\n'), ...g('ls-files', '--others', '--exclude-standard').split('\n')])].filter(Boolean);
|
|
379
392
|
if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
|
|
380
393
|
if (!changed.length) return speak ? '🐴 sawbuck: nothing changed to look up (no working edits, and no previous commit to read).' : '';
|
|
381
394
|
const fileArgs = changed.flatMap((f) => ['--file', f]);
|
|
382
395
|
let o = '';
|
|
383
396
|
try { o = execFileSync(process.execPath, [join(HERE, 'rank.mjs'), '--repo', repo, ...fileArgs, '--top', '3'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); }
|
|
384
397
|
catch (e) { o = (e.stdout || '').toString(); }
|
|
385
|
-
|
|
398
|
+
const erased = erasedBlock(repo, changed);
|
|
399
|
+
if (/relevant history/.test(o)) return [o.trimEnd(), erased].filter(Boolean).join('\n');
|
|
400
|
+
if (erased) return erased;
|
|
386
401
|
return speak ? '🐴 sawbuck: no notable history for the files you are touching (a clean trail).' : '';
|
|
387
402
|
}
|
|
388
403
|
|
|
@@ -627,6 +642,8 @@ function purgeRecord(repo) {
|
|
|
627
642
|
const p = join(repo, d);
|
|
628
643
|
try { if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); n++; out(`sawbuck: purged ${p}`); } } catch {}
|
|
629
644
|
}
|
|
645
|
+
const outcomes = join(repo, '.promptwheel', 'outcomes.jsonl'); // the rest of .promptwheel/ is the signed contract: yours to keep or delete
|
|
646
|
+
try { if (existsSync(outcomes)) { rmSync(outcomes, { force: true }); n++; out(`sawbuck: purged ${outcomes}`); } } catch {}
|
|
630
647
|
if (!n) out('sawbuck: no record to purge');
|
|
631
648
|
}
|
|
632
649
|
// is `sawbuck` resolvable on PATH? (SAWBUCK_NO_PATH_CHECK forces the strict/offline answer)
|