sawbuck 0.1.47 → 0.1.48
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.md +1 -1
- package/README.md +25 -9
- package/bins.mjs +1 -1
- package/contract-cmd.mjs +1 -1
- package/debt.mjs +1 -1
- package/memory.mjs +5 -13
- package/package.json +2 -2
- package/rank.mjs +2 -3
- package/reflect.mjs +6 -12
- package/rules.mjs +9 -2
- package/sawbuck-hook.mjs +2 -7
- package/sawbuck.mjs +16 -11
- package/shape.mjs +2 -2
- package/status.mjs +1 -5
package/CONTRACT.md
CHANGED
|
@@ -89,7 +89,7 @@ A win is **`gamed`** when it does not survive a **source-only re-run**: revert e
|
|
|
89
89
|
|
|
90
90
|
## 4. The context record (the trail knowledge)
|
|
91
91
|
|
|
92
|
-
A conforming implementation also carries the repo's history as deterministic memory: a ledger derived from git (hotspots, reverted approaches, suppressed or weakened tests, fragile areas), surfaced
|
|
92
|
+
A conforming implementation also carries the repo's history as deterministic memory: a ledger derived from git (hotspots, reverted approaches, suppressed or weakened tests, fragile areas), surfaced on demand as a ranked slice for the files being touched. It is a projection of git, never a second source of truth, so it is always reconstructable and never drifts from what actually happened.
|
|
93
93
|
|
|
94
94
|
---
|
|
95
95
|
|
package/README.md
CHANGED
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
It re-proves 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 reads as gamed.
|
|
6
6
|
|
|
7
|
-
> Sawbuck is the reference implementation of the [Verified-Outcome Contract](CONTRACT.md): the open interface for a coding loop you can trust.
|
|
8
|
-
|
|
9
7
|
In Claude Code, install the plugin and keep working:
|
|
10
8
|
|
|
11
9
|
```
|
|
@@ -13,7 +11,7 @@ In Claude Code, install the plugin and keep working:
|
|
|
13
11
|
/plugin install sawbuck-reins@sawbuck
|
|
14
12
|
```
|
|
15
13
|
|
|
16
|
-
The first time you open a git repository with no rules, Claude finishes your task and offers once to add them. Say yes: it writes the rule, sets your protected paths, and
|
|
14
|
+
The first time you open a git repository with no rules, Claude finishes your task and offers once to add them. Say yes: it writes the rule, sets your protected paths, and watches every turn: protected paths at the end of each turn, each commit re-proved from the source.
|
|
17
15
|
|
|
18
16
|
Or from the command line:
|
|
19
17
|
|
|
@@ -24,7 +22,7 @@ npx sawbuck check
|
|
|
24
22
|
# 2 · give this repo its rules: the written rule, protected paths, and your tests if it has no config yet
|
|
25
23
|
npm i -g sawbuck
|
|
26
24
|
sawbuck contract # --full adds supply-chain guards, a repo lock and, on a GitHub repo, a CI check
|
|
27
|
-
sawbuck attach #
|
|
25
|
+
sawbuck attach # watch every Claude Code turn (--shared for a team file, --git for any other agent)
|
|
28
26
|
```
|
|
29
27
|
|
|
30
28
|
**Zero-config:** it finds npm, pytest, cargo and go tests on its own, so in those repos there is nothing to write to start. Anything else (a Makefile target, say) reads inconclusive until you set the command in `promptwheel.config.json`.
|
|
@@ -46,17 +44,18 @@ $ sawbuck check # after fixing the source and restoring the test
|
|
|
46
44
|
(it survives a source-only re-run).
|
|
47
45
|
```
|
|
48
46
|
|
|
49
|
-
Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `64` could not run (not a git repo, no commit, a bad ref, an unknown flag, a bad or missing flag value, a bad config, or `--working` with nothing uncommitted, and it says which); no detectable test command reads `3` inconclusive, and says so.
|
|
47
|
+
Exit codes: `0` pass (verified, held, or allowed) · `1` sent back (a regression or a protected path) · `2` gamed · `3` inconclusive · `64` could not run (not a git repo, no commit, a bad ref, an unknown flag, a bad or missing flag value, a bad config, or `--working` with nothing uncommitted, and it says which); no detectable test command reads `3` inconclusive, and says so.
|
|
50
48
|
|
|
51
49
|
## What it does
|
|
52
50
|
|
|
53
|
-
- **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` lets a change through with a person's reason, and the check says it was allowed, never verified. A `Sawbuck-Allow:` commit trailer clears nothing in `sawbuck check`, the Stop hook or the git hook, signed or not, because an agent can write a commit message. Red-teamed across 9 ecosystems
|
|
51
|
+
- **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` lets a change through with a person's reason, and the check says it was allowed, never verified. A `Sawbuck-Allow:` commit trailer clears nothing in `sawbuck check`, the Stop hook or the git hook, signed or not, because an agent can write a commit message. Red-teamed in 26 scenarios across 9 ecosystems: of 22 cheats, 12 were caught and 10 got through, each one a limit the report lists, and all 4 honest fixes passed ([promptwheel/bench/REDTEAM.md](https://github.com/promptwheel-ai/promptwheel/blob/main/bench/REDTEAM.md)).
|
|
54
52
|
- **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.
|
|
55
53
|
- **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.
|
|
56
54
|
- **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. It says watching only while hooks are wired for this repo, not because an earlier attach left its record on disk. On-demand, so it is quiet until you go looking.
|
|
57
55
|
- **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
|
|
58
56
|
- **Clean on and off.** `attach` writes a git-excluded `.claude/settings.local.json`, the lines in `.git/info/exclude` that keep its files out of `git status`, a receipt under `.git/sawbuck/`, a line in `~/.sawbuck/registry.json` (the list `detach --all` reads), and in a signed repo `.sawbuck/state.json` (`attach --git` writes the post-commit hook and `.sawbuck/state.json` in place of the settings file); `attach --dry-run` lists all of it. `detach` removes the hooks and the receipt, and still removes the hooks when the receipt is lost; `detach --dry-run` lists what would go. A post-commit hook that was there before `attach --git` is put back byte for byte. Signed contract files stay because they are yours to review. The record (`.sawbuck/`, the logbook files) stays too, still out of `git status`, until `detach --purge` deletes it and the exclude lines. `detach --verify` proves the wiring is gone and names what is left.
|
|
59
57
|
- **A limit of the hooks file.** `.claude/settings.local.json` is kept out of git, so taking the hooks out of it shows in no diff, and the check cannot see it. `sawbuck doctor` can: it reads the receipt and says the hooks are gone, or that the set is incomplete (a missing Stop hook, say). `sawbuck attach --shared` avoids it: the hooks go in a tracked file, and a removal shows in git.
|
|
58
|
+
- **A limit of the turn hook.** The hook re-runs your tests on commits, not on uncommitted work. A test edited to go green and left uncommitted is not sent back at the end of that turn. It is caught when it is committed, and `sawbuck check` reads it on demand, which is what the written rule tells the agent to run before finishing.
|
|
60
59
|
- **A limit of the git hook.** `attach --git` pins the hook to the checkout it was attached from, so a commit made in a linked git worktree is not checked. A hooks folder inside the work tree (`core.hooksPath`, as husky sets it) is refused, and attach prints the line to add by hand; `detach`, `doctor` and `status` do not know about a line added that way.
|
|
61
60
|
|
|
62
61
|
## Commands
|
|
@@ -65,17 +64,34 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
|
|
|
65
64
|
|---|---|
|
|
66
65
|
| `sawbuck contract` | gives a repo its rules: protected paths and the written rule. With no config yet it writes one with your detected tests; an existing config is kept as it is, and gets no tests added. `--full` adds supply-chain guards, a repo lock and a CI check (only with a github.com origin and a real test command; it says when it skips it). `--strict` makes them block, also after an earlier `--full`. `--dry-run` previews |
|
|
67
66
|
| `sawbuck check [--base R --head R] [--repeat N] [--allow "reason"] [--json]` | verify your latest change (working tree if dirty, else the last commit, or a range); zero-config, no attach, no record. Every `promptwheel run` flag passes through: this is the engine with a plain-spoken verdict |
|
|
68
|
-
| `sawbuck attach [--user \| --shared \| --git]` |
|
|
67
|
+
| `sawbuck attach [--user \| --shared \| --git]` | watch every Claude Code turn here: protected paths at the end of each turn, a source-only re-run on each commit. `--user`: in every repo that has a `promptwheel.config.json`. `--shared`: a tracked `.claude/settings.json` a team commits, with no path from your machine in it. `--git`: a post-commit hook, so every commit is checked whatever made it, from the first one after attach (the very first commit of a repo has nothing to be checked against). It leaves a hook alone if it is not a shell script or not executable, and says what to add by hand |
|
|
69
68
|
| `sawbuck detach [--user] [--all] [--verify] [--purge] [--dry-run]` | remove the hooks; no flag removes whatever is wired here, with or without the receipt, `--all` does every repo. `--purge` also deletes the record, `--dry-run` only lists what would go |
|
|
70
69
|
| `/sawbuck-reins:audit` | finds rules in your agent files that can pull against each other, and proposes which wins. No history needed |
|
|
71
70
|
| `/sawbuck-reins:rules` | proposes the rules you keep enforcing, from your own messages, each with quotes; you pick what to keep. Tested on one person so far |
|
|
72
71
|
| `/sawbuck-reins:shape` | what you push on, from a sample of your own messages, as a card. It also reads up to 120 past turns and names habits the agent has that you never push on, each with the agent's own words. Leads from one read, tested on one person |
|
|
72
|
+
| `sawbuck init` | writes only the config: your detected tests and the tripwires, with notes from your history. `contract` runs it for you when the repo has no config yet |
|
|
73
73
|
| `sawbuck status` | the inventory (caught over time, debt now, armed state, what you keep saying), in one view; `--log` for the recent per-check feed |
|
|
74
74
|
| `sawbuck debt [repo]` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
|
|
75
75
|
| `sawbuck context` | the ranked relevant history for the current change |
|
|
76
76
|
| `sawbuck doctor [--all]` · `detach --verify` | run what each hook runs (project and user level) and say if it still works, if the set is incomplete, or if it is gone (exit 1), `--all` for every repo it is attached to; prove the wiring is gone |
|
|
77
77
|
| `sawbuck statusline` | a one-line status-line segment (🐴 watching / N caught) |
|
|
78
78
|
|
|
79
|
+
## Protected paths
|
|
80
|
+
|
|
81
|
+
`sawbuck contract` protects these by default. A change to one reads "sent back", whoever made it.
|
|
82
|
+
|
|
83
|
+
| rule | paths | how a person makes an exception |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `contract-frozen` | `promptwheel.config.json`, `sawbuck.config.json` | `sawbuck check --allow "<reason>"` |
|
|
86
|
+
| `hooks-frozen` | `.claude/settings.json`, `.claude/settings.local.json`, `.sawbuck/**` | `sawbuck check --allow "<reason>"` |
|
|
87
|
+
| `workflows-frozen` | `.github/workflows/**` | remove the rule first |
|
|
88
|
+
| `registry-config-frozen` | `.npmrc`, `.yarnrc`, `.yarnrc.yml` | remove the rule first |
|
|
89
|
+
| `build-images-frozen` | `Dockerfile*`, `docker-compose*` | remove the rule first |
|
|
90
|
+
| `publish-scripts-frozen` | `scripts/release*`, `scripts/publish*` | remove the rule first |
|
|
91
|
+
| `codeowners-frozen` | `CODEOWNERS` | remove the rule first |
|
|
92
|
+
|
|
93
|
+
To remove a rule, delete its entry under `mandates` in `promptwheel.config.json`, in a commit of its own. That file is protected too, so that commit takes `--allow`. Then make the change. To protect another path, add an entry the same way: `{"name": "billing-frozen", "paths": ["src/billing/**"], "reason": "billing changes go through review"}`.
|
|
94
|
+
|
|
79
95
|
## Give your agent the rule
|
|
80
96
|
|
|
81
97
|
`sawbuck contract` and `sawbuck init` write this block into your `AGENTS.md` for you (and `CLAUDE.md` / `.cursorrules` if present; `init --no-agent-block` skips it), and `sawbuck attach` 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:
|
|
@@ -120,9 +136,9 @@ Debt is on demand: `sawbuck debt`, and `sawbuck context` for the files in hand.
|
|
|
120
136
|
|
|
121
137
|
## The engines
|
|
122
138
|
|
|
123
|
-
Sawbuck is thin glue over two
|
|
139
|
+
Sawbuck is thin glue over two engines:
|
|
124
140
|
|
|
125
|
-
- **the check** : the deterministic outcome gate, a
|
|
141
|
+
- **the check** : the deterministic outcome gate, written up as a draft spec (the [Verified-Outcome Contract](CONTRACT.md)). Reference implementation: **promptwheel**. You plug in *what* to measure (your tests, lint, coverage, an eval); the source re-proof is the fixed part. Embed it with `import { check }`, or check any tool's verdict with `conformsToContract()` from the same package.
|
|
126
142
|
- **the memory** : the relevant history for the files you touch, plus the live debt, from **@promptwheel/logbook** (git history as agent memory; also ships an MCP server).
|
|
127
143
|
|
|
128
144
|
Apache 2.0.
|
package/bins.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// bins.mjs: resolve the two engine CLIs once, for every sawbuck entry point.
|
|
2
2
|
// Order: env override (PW_BIN / LB_BIN), the installed package, then the dev sibling checkout.
|
|
3
|
-
//
|
|
3
|
+
// Logbook is reached only through memory.mjs; LB lives
|
|
4
4
|
// here so the resolver is written once, not per file.
|
|
5
5
|
|
|
6
6
|
import { dirname, join } from 'node:path';
|
package/contract-cmd.mjs
CHANGED
|
@@ -115,4 +115,4 @@ try { files = execFileSync('git', ['-C', repo, 'ls-files'], { encoding: 'utf8',
|
|
|
115
115
|
const seen = new Set();
|
|
116
116
|
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]); }
|
|
117
117
|
if (seen.size) process.stdout.write(`\n To protect product code too, add these protected paths under "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`);
|
|
118
|
-
if (!dry) process.stdout.write(`\n Commit promptwheel.config.json and AGENTS.md${full ? ', promptwheel.lock.json and .promptwheel/packs/' : ''}. Then sawbuck attach
|
|
118
|
+
if (!dry) process.stdout.write(`\n Commit promptwheel.config.json and AGENTS.md${full ? ', promptwheel.lock.json and .promptwheel/packs/' : ''}. Then sawbuck attach watches every turn: protected paths at turn end, each commit re-proved.\n`);
|
package/debt.mjs
CHANGED
|
@@ -37,7 +37,7 @@ const asJson = process.argv.includes('--json');
|
|
|
37
37
|
// events give fragility + fall-back dates (Logbook's facts). Best-effort.
|
|
38
38
|
const events = memEvents(repo);
|
|
39
39
|
|
|
40
|
-
// the live suppression ledger via the
|
|
40
|
+
// the live suppression ledger via the Logbook adapter. Each
|
|
41
41
|
// item arrives classified (documented/testSkip) and with its line content and blame date (`since`),
|
|
42
42
|
// so this file does not import Logbook's pattern or gating. null = Logbook FAILED
|
|
43
43
|
// (unmeasurable is NOT clean, refuse and exit nonzero); [] = a genuinely clean tree.
|
package/memory.mjs
CHANGED
|
@@ -16,7 +16,6 @@ function parseArray(out) {
|
|
|
16
16
|
|
|
17
17
|
// --- debt sub-classification (Sawbuck policy; applied once so the ledger and the per-turn surface
|
|
18
18
|
// agree).
|
|
19
|
-
export const isTestSkip = (m) => /\bit\.skip\b|\btest\.skip\b|xit\(|xdescribe\(|\bdescribe\.skip\b|pytest\.mark\.skip|unittest\.skip|\bt\.Skip\b|@Disabled\b|@Ignore\b|^\[Ignore\b|^Skip\s*=|^#\[ignore|markTestSkipped|markTestIncomplete/.test(String(m).trim());
|
|
20
19
|
export const isDocumented = (c) => {
|
|
21
20
|
c = String(c || '');
|
|
22
21
|
if (/eslint-disable/.test(c)) return /eslint-disable(?:-next-line|-line)?\s+\S/.test(c);
|
|
@@ -27,12 +26,11 @@ export const isDocumented = (c) => {
|
|
|
27
26
|
return /\(\s*["']/.test(c) || /=\s*["']/.test(c);
|
|
28
27
|
};
|
|
29
28
|
|
|
30
|
-
const classify = (r
|
|
29
|
+
const classify = (r) => ({
|
|
31
30
|
file: r.file, line: r.line, kind: String(r.kind ?? '').trim(), content: r.content ?? '',
|
|
32
31
|
since: r.since ?? null, sha: r.sha ?? null,
|
|
33
32
|
documented: r.content ? isDocumented(r.content) : false,
|
|
34
|
-
testSkip:
|
|
35
|
-
source,
|
|
33
|
+
testSkip: r.testSkip === true, // Logbook classifies it
|
|
36
34
|
});
|
|
37
35
|
|
|
38
36
|
// events(): the raw git event record, the ranking input. [] on failure, exactly as "no history".
|
|
@@ -45,16 +43,10 @@ export function events(repo) {
|
|
|
45
43
|
// debt(): live suppressions still in the tree, each classified. null when Logbook could not answer.
|
|
46
44
|
export function debt(repo, { paths = null, ref = null } = {}) {
|
|
47
45
|
const out = run(LB, repo, ['audit', '--json', ...(ref ? ['--ref', ref] : []), ...((paths || []).flatMap((f) => ['--file', f]))]);
|
|
48
|
-
return out === null ? null : parseArray(out).map(
|
|
46
|
+
return out === null ? null : parseArray(out).map(classify);
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
// record(): keep a machine finding as an annotation. Best-effort, never throws into the caller.
|
|
52
|
-
export function record(repo, { sha, reason,
|
|
53
|
-
|
|
54
|
-
const exec = (a) => execFileSync(process.execPath, [LB, ...a], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
55
|
-
try {
|
|
56
|
-
try { exec(['annotate', sha, text, ...(side ? ['--side', side] : []), ...(evidenceFile ? ['--evidence-file', evidenceFile] : []), '--by', by]); }
|
|
57
|
-
catch { exec(['annotate', sha, text, '--by', by]); }
|
|
58
|
-
return true;
|
|
59
|
-
} catch { return false; }
|
|
50
|
+
export function record(repo, { sha, reason, by = 'sawbuck' }) {
|
|
51
|
+
return run(LB, repo, ['annotate', sha, String(reason).slice(0, 280), '--by', by]) !== null;
|
|
60
52
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.48",
|
|
4
|
+
"description": "Write down the rules your agent follows, and prove they held. 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 untested.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"engines": {
|
package/rank.mjs
CHANGED
|
@@ -77,11 +77,10 @@ const normMult = (p, kind) => {
|
|
|
77
77
|
// The "slider": debt rises on its own, but gently. A disabled test or an undocumented suppression
|
|
78
78
|
// on a touched file always surfaces; a file that is churning (hot / reverted) surfaces any debt;
|
|
79
79
|
// a stable file's routine documented suppressions stay quiet until they pile past DEBT_MIN. Tune
|
|
80
|
-
// with SAWBUCK_DEBT_MIN. This is how debt
|
|
80
|
+
// with SAWBUCK_DEBT_MIN. This is how debt shows up in `sawbuck context` with no command of its own.
|
|
81
81
|
const DEBT_MIN = Math.max(1, +(process.env.SAWBUCK_DEBT_MIN) || 6);
|
|
82
82
|
|
|
83
|
-
// Still-live filter + ambient debt, via the
|
|
84
|
-
// capability), scoped to the touched paths so the per-turn hook stays cheap. A suppression on a path
|
|
83
|
+
// Still-live filter + ambient debt, via the Logbook adapter, scoped to the touched paths so the read stays cheap. A suppression on a path
|
|
85
84
|
// now CLEAN of suppressions is churn, not current risk (the study: AutoMapper carries 4 live
|
|
86
85
|
// `[Ignore]` vs ~158/repo across C# history). The adapter runs Logbook's own detection + gating
|
|
87
86
|
// and returns classified suppressions, so the glue never imports its pattern. null = the
|
package/reflect.mjs
CHANGED
|
@@ -16,14 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
-
import { join
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { join } from 'node:path';
|
|
21
20
|
import { record as memRecord } from './memory.mjs';
|
|
22
21
|
import { PW } from './bins.mjs';
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
// The MEMORY engine is reached only through memory.mjs (the Memory Contract seam), never
|
|
26
|
-
// resolved or shelled directly here.
|
|
23
|
+
// Logbook is reached only through memory.mjs, never resolved or shelled directly here.
|
|
27
24
|
|
|
28
25
|
const flag = (name, def) => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : def; };
|
|
29
26
|
const repo = flag('--repo', process.cwd());
|
|
@@ -110,15 +107,12 @@ const reason = kind === 'gamed'
|
|
|
110
107
|
: `sawbuck: a change here regressed a guarded metric beyond noise`;
|
|
111
108
|
|
|
112
109
|
// --- 3) record the finding in memory (unreviewed, machine-authored note) ----
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
// over the same span is the same catch, not a new one: no second note, no second tick.
|
|
117
|
-
const repeatOfStanding = standing && kind === 'gamed';
|
|
118
|
-
if (!repeatOfStanding) memRecord(repo, { sha, reason: reason.slice(0, 280), side: 'diff', evidenceFile, by: 'sawbuck' });
|
|
110
|
+
// A note on the commit, through the Logbook adapter. A gamed verdict still standing over the same span never
|
|
111
|
+
// gets here (it exited above), so one catch is one note and one tick.
|
|
112
|
+
memRecord(repo, { sha, reason: reason.slice(0, 280), by: 'sawbuck' });
|
|
119
113
|
|
|
120
114
|
// --- 4) tick the results record (the visible proof-of-work) -----------------
|
|
121
|
-
if (
|
|
115
|
+
if (kind === 'gamed') rec.gamed += 1; else rec.regressions += 1;
|
|
122
116
|
rec.latest = { sha: sha.slice(0, 8), kind, base: baseSha || null, file: evidenceFile, reason: reason.replace(/^sawbuck: /, '').slice(0, 140), at: new Date().toISOString(), by: by || null };
|
|
123
117
|
writeRec();
|
|
124
118
|
|
package/rules.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Find the rules you keep enforcing and never wrote down. `prepare` gathers what you typed to Claude Code, on this
|
|
2
2
|
// machine, with copies and likely secrets removed. The agent proposes rules from it, each with quotes. `check` proves
|
|
3
3
|
// every quote is really yours: a rule with fewer than two real quotes is dropped. Nothing is added to any file here.
|
|
4
|
-
|
|
5
|
-
import {
|
|
4
|
+
// Once the check has run, the gathered messages and the candidates are deleted: they were for that one read.
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, chmodSync, rmSync } from 'node:fs';
|
|
6
|
+
import { join, resolve, dirname } from 'node:path';
|
|
6
7
|
import { homedir } from 'node:os';
|
|
7
8
|
import { readHistory, typedText } from './history.mjs';
|
|
8
9
|
|
|
@@ -10,6 +11,11 @@ const MAX_LINES = 3000;
|
|
|
10
11
|
export const SECRET = /(sk-[a-z0-9_-]{16,}|gh[pousr]_[a-z0-9]{20,}|github_pat_\w{20,}|AKIA[0-9A-Z]{16}|xox[baprs]-[\w-]{10,}|eyJ[\w-]{20,}\.[\w-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY|\b[a-f0-9]{40,}\b|(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)/i;
|
|
11
12
|
const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
12
13
|
const paths = (home) => ({ dir: join(home, '.sawbuck'), input: join(home, '.sawbuck', 'rule-input.txt') });
|
|
14
|
+
// remove what one read gathered. A file the model wrote counts only when it sits in ~/.sawbuck: anywhere else it is the person's own.
|
|
15
|
+
export function forget(home, ...files) {
|
|
16
|
+
const dir = resolve(paths(home).dir);
|
|
17
|
+
for (const f of files) if (f && dirname(resolve(f)) === dir) rmSync(f, { force: true });
|
|
18
|
+
}
|
|
13
19
|
|
|
14
20
|
export function prepareRules({ home = homedir() } = {}) {
|
|
15
21
|
const found = [];
|
|
@@ -60,6 +66,7 @@ export function rulesCli(args, { home = homedir() } = {}) {
|
|
|
60
66
|
if (!file || !existsSync(file) || !statSync(file).isFile()) return '🐴 sawbuck: --check needs the candidates file.';
|
|
61
67
|
if (!existsSync(paths(home).input)) return '🐴 sawbuck: run sawbuck rules --prepare first.';
|
|
62
68
|
const { kept, dropped } = checkRules(file, { home });
|
|
69
|
+
forget(home, paths(home).input, file);
|
|
63
70
|
const out = [`🐴 ${kept.length} rules have at least 2 quotes that are really yours${dropped.length ? `, ${dropped.length} dropped` : ''}.`];
|
|
64
71
|
kept.forEach((r, n) => { out.push('', `${n + 1}. ${r.rule}`); for (const q of r.quotes.slice(0, 3)) out.push(` "${q.slice(0, 110)}"`); });
|
|
65
72
|
return out.join('\n');
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -6,11 +6,11 @@ import { ensureExcluded } from './exclude.mjs';
|
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from 'node:fs';
|
|
9
|
-
import { join, dirname
|
|
9
|
+
import { join, dirname } 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,
|
|
13
|
+
import { mandateHits, hashOf, 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
|
|
@@ -182,8 +182,3 @@ if (total > (st.shownTotal || 0)) {
|
|
|
182
182
|
st.shownTotal = total;
|
|
183
183
|
}
|
|
184
184
|
saveState();
|
|
185
|
-
|
|
186
|
-
// Keep the record dirs out of `git status` via .git/info/exclude, so Sawbuck never
|
|
187
|
-
// dirties the worktree, including under `attach --user`, which is global and cannot
|
|
188
|
-
// set this up per-repo at attach time. Idempotent; markers match the CLI's so a
|
|
189
|
-
// per-repo `detach` removes them.
|
package/sawbuck.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { basename, join, dirname, resolve, relative } from 'node:path';
|
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { homedir } from 'node:os';
|
|
22
22
|
import { PW } from './bins.mjs';
|
|
23
|
-
import { frameBlock, mandatesInForce, protectedDirty, blobAt, lockState } from './contract.mjs';
|
|
23
|
+
import { frameBlock, mandatesInForce, protectedDirty, protectedAmong, blobAt, lockState } from './contract.mjs';
|
|
24
24
|
|
|
25
25
|
const SELF = fileURLToPath(import.meta.url);
|
|
26
26
|
const HERE = dirname(SELF);
|
|
@@ -61,12 +61,6 @@ function detectedTests(repo) {
|
|
|
61
61
|
const metricLive = (m) => !!m && !m.inert && m.status !== 'unmeasurable' && m.before != null;
|
|
62
62
|
const S_END = '# <<< sawbuck <<<';
|
|
63
63
|
const BLOCK_RE = new RegExp(`${S_START}[\\s\\S]*?${S_END}\\n?`, 'g'); // exactly what attach --git puts in, so taking it out leaves the bytes that were there
|
|
64
|
-
// the record/state dirs the engines write into the worktree; excluded locally so
|
|
65
|
-
// attach never dirties `git status`.
|
|
66
|
-
// The record Sawbuck keeps on disk but must never let dirty git status: the engine records
|
|
67
|
-
// plus the logbook's root working-set (ledger cache, digests, annotation planes). It is
|
|
68
|
-
// memory, not the user's source, a user who wants LOGBOOK.md committed still can explicitly
|
|
69
|
-
// (exclude only affects UNtracked files).
|
|
70
64
|
|
|
71
65
|
const argv = process.argv.slice(2);
|
|
72
66
|
const cmd = argv[0];
|
|
@@ -87,7 +81,8 @@ Usage:
|
|
|
87
81
|
Verify your latest change right now (working tree if dirty, else the last
|
|
88
82
|
commit, or the range you name). Zero-config, no attach, no hook. The
|
|
89
83
|
fastest way to see it work. Every promptwheel run flag passes through.
|
|
90
|
-
Exit 0 verified
|
|
84
|
+
Exit 0 pass (verified, held, or allowed) · 1 sent back (a regression or a
|
|
85
|
+
protected path) · 2 gamed · 3 inconclusive · 64 could not run
|
|
91
86
|
(--working with nothing uncommitted is 64 too)
|
|
92
87
|
sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block] [--force]
|
|
93
88
|
Write promptwheel.config.json: your detected tests and the tripwires, with
|
|
@@ -95,7 +90,8 @@ Usage:
|
|
|
95
90
|
AGENTS.md (--no-agent-block to skip). Wizard in a terminal, non-interactive in
|
|
96
91
|
a pipe/CI (or --yes). An existing config needs --force.
|
|
97
92
|
sawbuck attach [repo] [--user | --shared | --git] [--dry-run]
|
|
98
|
-
|
|
93
|
+
Watch every Claude Code turn in this repo: protected paths at the end of each
|
|
94
|
+
turn, a source-only re-run on each commit. --user: every repo that has a
|
|
99
95
|
promptwheel.config.json. --shared: a tracked settings file a team commits.
|
|
100
96
|
--git: a post-commit hook, for any other agent or none (commits in a linked
|
|
101
97
|
worktree are not checked). --dry-run prints every file it would write
|
|
@@ -151,6 +147,7 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
151
147
|
|
|
152
148
|
sawbuck contract give this repo its rules: protected paths, your tests
|
|
153
149
|
sawbuck check verify your latest change right now
|
|
150
|
+
sawbuck attach make it automatic in Claude Code: protected paths each turn, every commit re-proved
|
|
154
151
|
sawbuck status what it has caught here
|
|
155
152
|
|
|
156
153
|
sawbuck help --all everything else`);
|
|
@@ -243,7 +240,14 @@ switch (cmd) {
|
|
|
243
240
|
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.');
|
|
244
241
|
if (!dirty && /^(Sawbuck|Promptwheel)-Allow:/im.test(g('log', '--format=%B', `${base || 'HEAD~1'}..${head || 'HEAD'}`))) out('🐴 note: a Sawbuck-Allow trailer does not clear this, because an agent can write a commit message. A person passes --allow "<reason>".');
|
|
245
242
|
}
|
|
246
|
-
else if (rep.verdict === 'fail' && (rep.mandates || []).length)
|
|
243
|
+
else if (rep.verdict === 'fail' && (rep.mandates || []).length) {
|
|
244
|
+
out('🐴 ✗ sent back: a protected path changed, however green the rest is.');
|
|
245
|
+
// the way through, for a person who meant it: two of the default rules take a reason, the rest are lifted first
|
|
246
|
+
const names = (l) => l.map((x) => `"${x.name}"`).join(', ');
|
|
247
|
+
const soft = rep.mandates.filter((x) => x.allowable), hard = rep.mandates.filter((x) => !x.allowable);
|
|
248
|
+
if (soft.length) out(`🐴 if a person meant this: sawbuck check --allow "<reason>" accepts ${names(soft)}, and the result shows the reason.`);
|
|
249
|
+
if (hard.length) out(`🐴 ${names(hard)} ${hard.length > 1 ? 'take' : 'takes'} no --allow. To change that path on purpose, remove that rule from promptwheel.config.json in a commit of its own (that edit takes --allow), then make the change.`);
|
|
250
|
+
}
|
|
247
251
|
else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
|
|
248
252
|
else out(`🐴 ≈ inconclusive: ${inconclusiveWhy(rep, repo)}`);
|
|
249
253
|
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.');
|
|
@@ -468,7 +472,7 @@ function verifyAttach(repo) {
|
|
|
468
472
|
const { rep } = gateReport(repo, ['run', '--base', base, '--head', 'HEAD']);
|
|
469
473
|
const m = rep && (rep.metrics || [])[0];
|
|
470
474
|
if (metricLive(m)) out(` ✓ I can measure "${m.name}" here. On each commit I'll verify a win survived a source-only re-run, and flag it if it did not.`);
|
|
471
|
-
else out(` ⚠ I could not measure anything here yet: ${rep ? inconclusiveWhy(rep, repo) : 'the check did not run'}
|
|
475
|
+
else out(` ⚠ I could not measure anything here yet: ${rep ? inconclusiveWhy(rep, repo) : 'the check did not run'}`);
|
|
472
476
|
}
|
|
473
477
|
|
|
474
478
|
// --- the attach receipt (out of the worktree, inside .git) ------------------
|
|
@@ -691,6 +695,7 @@ function wireClaude(repo, opts) {
|
|
|
691
695
|
const kind = user ? 'USER-LEVEL' : shared ? 'shared project' : 'project';
|
|
692
696
|
out(upgrading ? `sawbuck: updated the ${kind} Claude Code hooks in ${f} to this version` : `sawbuck: wired ${kind} Claude Code hooks in ${f}`);
|
|
693
697
|
if (shared) out(` commit ${relative(repo, f)} so the team gets the check. It runs sawbuck ${version} through npx on every machine.`);
|
|
698
|
+
if (shared && protectedAmong(repo, [relative(repo, f)]).length) out(` That file is a protected path in this repo, so the check on its commit reads "sent back" unless a person passes --allow "team hooks" (on a pull request: a comment, sawbuck-allow: team hooks).`);
|
|
694
699
|
if (!user && !existsSync(join(repo, 'AGENTS.md')) && !existsSync(join(repo, 'CLAUDE.md'))) out(' no written rule here yet, and that is what changes the agent: run sawbuck contract.');
|
|
695
700
|
if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
|
|
696
701
|
}
|
package/shape.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmS
|
|
|
11
11
|
import { execFileSync } from 'node:child_process';
|
|
12
12
|
import { join, dirname } from 'node:path';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
|
-
import { prepareRules, SECRET } from './rules.mjs';
|
|
14
|
+
import { prepareRules, SECRET, forget } from './rules.mjs';
|
|
15
15
|
import { readHistory, typedText } from './history.mjs';
|
|
16
16
|
|
|
17
17
|
export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
|
|
@@ -117,7 +117,6 @@ export function tally(labels) {
|
|
|
117
117
|
return { n: total, none, groups, ratings, shares, spokes: total >= MIN_SPOKES };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
// What was not said: each spoke we can measure from what the agent did, read against how often you push on it.
|
|
121
120
|
export function shapeSvg(t) {
|
|
122
121
|
const names = t.spokes ? SPOKES : Object.keys(GROUPS);
|
|
123
122
|
const vals = t.spokes ? names.map((s) => t.ratings[s] / 99) : names.map((g) => 0.25 + 0.75 * t.groups[g] / Math.max(1, ...Object.values(t.groups)));
|
|
@@ -172,6 +171,7 @@ export function shapeCli(args, { home = homedir() } = {}) {
|
|
|
172
171
|
}
|
|
173
172
|
if (!t.spokes) out.push('', ` Too few messages for the 11 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
|
|
174
173
|
appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares }) + '\n');
|
|
174
|
+
forget(home, p.input, p.rules, file); // the sampled messages and their labels were for this one read; the totals above are what stays
|
|
175
175
|
out.push(...notSaid(p));
|
|
176
176
|
out.push('', ` Card: ${p.svg}`);
|
|
177
177
|
if (args.includes('--png')) { const png = pngCard(p.svg); out.push(png ? ` Image to share: ${png}` : ' No image tool here (rsvg-convert or Chrome): open the card in a browser to save an image.'); }
|
package/status.mjs
CHANGED
|
@@ -21,9 +21,7 @@ const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (pro
|
|
|
21
21
|
const repo = resolve(flag('--repo', process.cwd()));
|
|
22
22
|
const asJson = process.argv.includes('--json');
|
|
23
23
|
|
|
24
|
-
const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
25
24
|
const readLines = (p) => { try { return readFileSync(p, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
|
|
26
|
-
const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
27
25
|
|
|
28
26
|
const ICON = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
|
|
29
27
|
|
|
@@ -38,8 +36,6 @@ for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
|
|
|
38
36
|
const seen = feed.length;
|
|
39
37
|
const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
|
|
40
38
|
const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
|
|
41
|
-
// the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file, in repoState; the
|
|
42
|
-
// recent-window average over roughly the last twenty scored turns, as the band and statusline show it)
|
|
43
39
|
|
|
44
40
|
// --- 2) the armed state: what it can measure here (shared with the statusline) ---
|
|
45
41
|
const isGit = !!existsSync(join(repo, '.git'));
|
|
@@ -147,7 +143,7 @@ if (!isGit) {
|
|
|
147
143
|
} else if (state === 'watching' && wired === 'user' && !hasConfig) {
|
|
148
144
|
out.push(' Measuring here · wired in at user level, and that check runs only in a repo that has a promptwheel.config.json. `sawbuck contract` writes one.');
|
|
149
145
|
} else if (state === 'watching') {
|
|
150
|
-
out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}.
|
|
146
|
+
out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}. Protected paths are checked at the end of each turn, and each commit is re-proved from the source.`);
|
|
151
147
|
} else if (state === 'ready') {
|
|
152
148
|
out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach\``);
|
|
153
149
|
} else {
|