sawbuck 0.1.49 โ†’ 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,10 +27,10 @@ sawbuck contract # --full adds supply-chain guards, a repo lock and, on
27
27
  sawbuck attach # watch every Claude Code turn (--shared for a team file, --git for any other agent)
28
28
  ```
29
29
 
30
- **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`.
30
+ **Zero-config:** it finds npm, pytest, cargo and go tests on its own, so in those repos there is nothing to write to start. That includes npm, yarn and pnpm workspaces: each workspace's own `node_modules` is linked into the clean copy the check runs in, and a sibling package is read at the commit being measured. If the tests import a sibling package's built output, they need the build first: `sawbuck init` adds it as `setup`. Anything else (a Makefile target, say) reads inconclusive until you set the command in `promptwheel.config.json`. When it cannot measure, it says which it was: tests that fail before and after, a dependency the clean copy is missing, or a `setup` command that failed.
31
31
  On attach it tells you exactly what it can measure here (and how to fix it if it can't).
32
32
  `sawbuck init` writes that config: your detected tests and the tripwires, with notes from your history. Coverage, latency or a cost budget you add to it by hand.
33
- In CI, the same check runs as the [promptwheel GitHub Action](https://github.com/promptwheel-ai/promptwheel#in-ci-and-in-claude-code) (`uses: promptwheel-ai/promptwheel@v0`); the debt gate below is this repo's own Action. For every project at once: `sawbuck attach --user`. Under `--user` the check runs only in repos that have a `promptwheel.config.json`.
33
+ In CI, the same check runs as the [promptwheel GitHub Action](https://github.com/promptwheel-ai/promptwheel#in-ci-and-in-claude-code) (`uses: promptwheel-ai/promptwheel@v0`, or pin it by commit: `sawbuck contract --full` writes the workflow with every action pinned by commit, its own included); the debt gate below is this repo's own Action. For every project at once: `sawbuck attach --user`. Under `--user` the check runs only in repos that have a `promptwheel.config.json`.
34
34
 
35
35
  One entry point each: a person types `sawbuck`; CI and libraries use `promptwheel` (the engine underneath, `import { check }`). `sawbuck check` passes every `promptwheel run` flag through, so you never need both.
36
36
 
@@ -57,6 +57,7 @@ Exit codes: `0` pass (verified, held, or allowed) ยท `1` sent back (a regression
57
57
  - **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
58
58
  - **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
59
  - **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.
60
+ - **What a check costs.** It runs your test command, and your `setup` command if the config has one, in a clean copy at each commit it compares, and once more when a test went from red to green. Under the Claude hooks that happens when a turn made a commit, not on every turn. If `setup` is a full production build, narrow it to what the tests need.
60
61
  - **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.
61
62
  - **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.
62
63
 
@@ -105,7 +106,7 @@ To remove a rule, delete its entry under `mandates` in `promptwheel.config.json`
105
106
  files before (reverts, weakened tests, fragile spots). Never skip, weaken, or delete a
106
107
  test to go green.
107
108
 
108
- **With `--full`.** It starts by watching: protected paths, new install scripts and sources outside the registry block from day one, and new dependencies and structure changes show as notes. `sawbuck contract --strict` blocks them all. When something fails, the check says what changed in words. If your tests can't run in CI, the check says so and doesn't fail the job, and protected paths are still enforced. To accept it on a pull request, someone with write access comments `sawbuck-allow: <reason>` and re-runs the check; the reason stays on the record. An agent signed in as that person could post the same comment, so branch protection and review still matter. Locally, use `--allow "<reason>"`. A commit trailer is not a person, so Sawbuck does not read one.
109
+ **With `--full`.** `pins_changed` is a fingerprint of the resolved dependency tree, not a count, so its value is a long number that only means changed or unchanged. The supply-chain guards read `unmeasurable` on the commit that adds them, because their scanners did not exist before it, and count from the next change. It starts by watching: protected paths, new install scripts and sources outside the registry block from day one, and new dependencies and structure changes show as notes. `sawbuck contract --strict` blocks them all. When something fails, the check says what changed in words. If your tests can't run in CI, the check says so and doesn't fail the job, and protected paths are still enforced. To accept it on a pull request, someone with write access comments `sawbuck-allow: <reason>` and re-runs the check; the reason stays on the record. An agent signed in as that person could post the same comment, so branch protection and review still matter. Locally, use `--allow "<reason>"`. A commit trailer is not a person, so Sawbuck does not read one.
109
110
 
110
111
  **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.
111
112
 
package/contract-cmd.mjs CHANGED
@@ -7,13 +7,17 @@ import './pipe.mjs';
7
7
  import { ensureExcluded } from './exclude.mjs';
8
8
  import { writeAgentBlock } from './agent-block.mjs';
9
9
  import { execFileSync } from 'node:child_process';
10
- import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, mkdirSync } from 'node:fs';
10
+ import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, mkdirSync, readdirSync, statSync, symlinkSync } from 'node:fs';
11
11
  import { join, dirname, resolve } from 'node:path';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { PW } from './bins.mjs';
15
15
 
16
16
  const HERE = dirname(fileURLToPath(import.meta.url));
17
+ // The check's own action is pinned by commit, like GitHub's: the commit the engine's release tag points at. A moving
18
+ // tag with write access to pull requests is the kind of thing the unpinned_execution guard exists to flag.
19
+ const PKG = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'));
20
+ const ACTION = `promptwheel-ai/promptwheel@${PKG.promptwheelAction} # v${PKG.dependencies.promptwheel}`;
17
21
  const repo = process.argv[2] || process.cwd();
18
22
  const dry = process.argv.includes('--dry-run');
19
23
  const strict = process.argv.includes('--strict');
@@ -36,7 +40,7 @@ jobs:
36
40
  ${npm ? ` - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
37
41
  with: { node-version: 22 }
38
42
  - run: npm ci --ignore-scripts
39
- ` : ''} - uses: promptwheel-ai/promptwheel@v0
43
+ ` : ''} - uses: ${ACTION}
40
44
  with: { inconclusive: pass }
41
45
  `;
42
46
  const cfgPath = join(repo, 'promptwheel.config.json');
@@ -57,9 +61,46 @@ try {
57
61
  const names = (l) => new Set((l || []).map((x) => x && x.name));
58
62
  const counts = (c) => `${new Set([...names(c.mandates), ...names(mandates)]).size} protected paths${full ? `, ${preset.metrics.length} supply-chain guards` : ''}`;
59
63
  if (dry) {
60
- let now = {}; try { if (had) now = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch {}
61
- const notes = strict ? (now.metrics || []).filter((m) => m && m.report && REPORT.has(m.name)).length : 0;
62
- process.stdout.write(`sawbuck contract would ${had ? 'update' : 'write'} ${cfgPath}: ${counts(now)}${full ? ', the scanners under .promptwheel/packs/, and promptwheel.lock.json' : ''}${notes ? `, and make ${notes} guards that are notes block` : ''}.\n`);
64
+ // The preview is the real run, done in a throwaway copy of what signing reads, so the two cannot drift apart:
65
+ // the files in the repo's top folder, an existing workflow, the scanners, the remote and the exclude file.
66
+ const shadow = mkdtempSync(join(tmpdir(), 'sawbuck-preview-'));
67
+ try {
68
+ const g = (...a) => execFileSync('git', ['-c', 'user.name=sawbuck', '-c', 'user.email=sawbuck@local', ...a], { cwd: shadow, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
69
+ g('init', '-q');
70
+ for (const e of readdirSync(repo, { withFileTypes: true })) if (e.isFile() && statSync(join(repo, e.name)).size < 32 * 1024 * 1024) cpSync(join(repo, e.name), join(shadow, e.name));
71
+ for (const rel of [join('.github', 'workflows', 'sawbuck.yml'), join('.promptwheel', 'packs')]) if (existsSync(join(repo, rel))) cpSync(join(repo, rel), join(shadow, rel), { recursive: true });
72
+ if (existsSync(join(repo, 'node_modules'))) { try { symlinkSync(join(repo, 'node_modules'), join(shadow, 'node_modules')); } catch { /* the lint check may not be offered in the preview */ } }
73
+ const exclude = (root) => resolve(root, execFileSync('git', ['-C', root, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8' }).trim());
74
+ let remote = ''; try { remote = execFileSync('git', ['-C', repo, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
75
+ if (remote) g('remote', 'add', 'origin', remote);
76
+ try { if (existsSync(exclude(repo))) { mkdirSync(dirname(exclude(shadow)), { recursive: true }); cpSync(exclude(repo), exclude(shadow)); } } catch {}
77
+ const read = (f) => { try { return readFileSync(f, 'utf8'); } catch { return ''; } };
78
+ const exWas = read(exclude(shadow));
79
+ g('add', '-A'); g('commit', '-q', '--allow-empty', '-m', 'as it is now');
80
+ run([fileURLToPath(import.meta.url), shadow, ...process.argv.slice(3).filter((a) => a !== '--dry-run')], shadow);
81
+ const rows = new Map();
82
+ for (const l of g('status', '--porcelain', '-uall').split('\n').filter(Boolean)) {
83
+ const f = l.slice(3).replace(/^(\.promptwheel\/packs\/supply-chain\/).*/, '$1');
84
+ if (f !== 'node_modules') rows.set(f, l.startsWith('??') ? 'new' : 'changed');
85
+ }
86
+ const after = existsSync(join(shadow, 'promptwheel.config.json')) ? JSON.parse(read(join(shadow, 'promptwheel.config.json'))) : { metrics: [], mandates: [] };
87
+ let now = {}; try { if (had) now = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch {}
88
+ const notes = strict ? (now.metrics || []).filter((m) => m && m.report && REPORT.has(m.name)).length : 0;
89
+ const what = {
90
+ 'promptwheel.config.json': `${counts(now)}, ${(after.metrics || []).length} checks in all (${(after.metrics || []).map((m) => m.name).join(', ')})${notes ? `, and ${notes} guards that are notes would block` : ''}`,
91
+ 'AGENTS.md': 'the written rule', 'CLAUDE.md': 'the written rule', '.cursorrules': 'the written rule',
92
+ '.github/workflows/sawbuck.yml': 'a CI check on pull requests',
93
+ 'promptwheel.lock.json': 'the shape of this repo',
94
+ '.promptwheel/packs/supply-chain/': 'the two scanners the supply-chain guards run',
95
+ '.gitignore': 'so the scanners reach git',
96
+ };
97
+ if (!rows.size) process.stdout.write('sawbuck contract --dry-run: already signed, nothing would change.\n');
98
+ else {
99
+ process.stdout.write('sawbuck contract --dry-run: nothing was written. A real run would write:\n');
100
+ for (const [f, how] of rows) process.stdout.write(` ${f.padEnd(34)} ${how}${what[f] ? ': ' + what[f] : ''}\n`);
101
+ if (read(exclude(shadow)) !== exWas) process.stdout.write(' and lines in .git/info/exclude that keep Sawbuck\'s record out of git status (local, never committed)\n');
102
+ }
103
+ } finally { rmSync(shadow, { recursive: true, force: true }); }
63
104
  } else {
64
105
  if (!had) run([join(HERE, 'init.mjs'), '-y', '--no-investigate'], repo);
65
106
  const cfg = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, 'utf8')) : { repeat: 1, metrics: [] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.49",
3
+ "version": "0.1.50",
4
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",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@promptwheel/logbook": "0.11.0",
37
- "promptwheel": "0.8.1"
37
+ "promptwheel": "0.8.2"
38
38
  },
39
39
  "files": [
40
40
  "sawbuck.mjs",
@@ -59,5 +59,6 @@
59
59
  "memory.mjs",
60
60
  "README.md",
61
61
  "CONTRACT.md"
62
- ]
62
+ ],
63
+ "promptwheelAction": "3558f779fc26d7fdf948ec06a1a3ae47ac55e742"
63
64
  }
package/sawbuck.mjs CHANGED
@@ -39,17 +39,25 @@ function gateReport(repo, a) {
39
39
  }
40
40
  // why nothing could be measured, in words that match the case: no test command, tests red on both sides, or a
41
41
  // win the source-only re-run could not apply. `sawbuck init` only where there is no config to edit yet.
42
+ // a setup command that failed, in words: "(exit 7) at the base and head refs: <its last line>". '' when setup worked.
43
+ const setupFailure = (rep) => { const s = rep && rep.setup; if (!s) return ''; const at = Object.keys(s), f = s[at[0]]; return `(exit ${f.exit}) at the ${at.join(' and ')} ref${at.length > 1 ? 's' : ''}${f.lastLine ? ': ' + f.lastLine : ''}`; };
42
44
  function inconclusiveWhy(rep, repo) {
43
45
  const ms = (rep && rep.metrics) || [];
44
46
  const m = ms.find((x) => x.inert) || ms[0];
45
47
  if (ms.some((x) => x.unprovable)) return 'a win here could not be re-proved, because the source-only re-run did not apply. Do not trust this green on its own.';
48
+ if (setupFailure(rep)) return `the setup command failed ${setupFailure(rep)}. Fix "setup" in promptwheel.config.json, then check again.`;
46
49
  const hasCfg = existsSync(join(repo, 'promptwheel.config.json'));
47
50
  let cmd = '';
48
51
  try { cmd = String((JSON.parse(readFileSync(join(repo, 'promptwheel.config.json'), 'utf8')).metrics || []).find((x) => x && x.name === (m && m.name))?.cmd || ''); } catch {}
49
52
  const placeholder = /set your test command/.test(cmd) || (!hasCfg && !detectedTests(repo));
50
53
  const where = hasCfg ? 'in promptwheel.config.json' : 'with `sawbuck init`, then in promptwheel.config.json';
51
54
  if (placeholder) return `there is no test command here, so nothing was measured. It finds npm, pytest, cargo and go tests on its own. Set yours ${where} (the cmd of "${(m && m.name) || 'tests_pass'}").`;
52
- return `"${(m && m.name) || 'tests_pass'}" failed before and after this change, so there was no win or loss to measure. If your tests are red, fix them first. If they pass for you, the clean copy the check runs in is missing something: set setup or linkDirs ${where}.`;
55
+ // tests that import a built folder (dist, build) or a sibling package's built output need the build to run first
56
+ let build = false; try { build = !!JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).scripts?.build; } catch {}
57
+ const needsBuild = !build ? '' : hasCfg ? ' This repo has a build script: if your tests need it, set "setup" to it in promptwheel.config.json.' : ' This repo has a build script, and tests often need it first: `sawbuck init` writes a config that runs it.';
58
+ if (m && m.cause === 'too-much-output') return `your test command was stopped: ${m.detail}. Make it quieter, or point the check at a smaller command ${where}.`;
59
+ if (m && m.cause === 'missing-dependency') return `the clean copy the check runs in is missing something your tests need: ${m.detail}. Link it with linkDirs, or install it with setup, ${where}.${needsBuild}`;
60
+ return `your tests fail before and after this change, so there was no win or loss to measure ("${(m && m.name) || 'tests_pass'}"). Fix the failing tests first. If they pass for you, the clean copy the check runs in is missing something: set setup or linkDirs ${where}.${needsBuild}`;
53
61
  }
54
62
  // the test commands the engine finds with no config (promptwheel's detectTestCmd)
55
63
  function detectedTests(repo) {
@@ -238,6 +246,7 @@ switch (cmd) {
238
246
  else if (rep.verdict === 'pass') out(`๐Ÿด โœ“ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
239
247
  else if (rep.verdict === 'gamed') {
240
248
  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.');
249
+ if (((rep.gate && rep.gate.weakened) || []).some((w) => w.change === 'measurement-edited')) out('๐Ÿด this change also edits how the check measures (setup, env or linkDirs in promptwheel.config.json), and a change is judged by the settings in force before it. Commit that edit on its own first, then check your fix again.');
241
250
  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>".');
242
251
  }
243
252
  else if (rep.verdict === 'fail' && (rep.mandates || []).length) {
@@ -250,6 +259,7 @@ switch (cmd) {
250
259
  }
251
260
  else if (rep.verdict === 'fail') out('๐Ÿด โœ— sent back: this change made something worse.');
252
261
  else out(`๐Ÿด โ‰ˆ inconclusive: ${inconclusiveWhy(rep, repo)}`);
262
+ if (rep.verdict !== 'inconclusive' && setupFailure(rep)) out(`๐Ÿด โš  the setup command failed ${setupFailure(rep)}. The check still ran, so see whether "setup" in promptwheel.config.json is still right.`);
253
263
  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.');
254
264
  for (const x of rep.explained || []) out(`๐Ÿด ${x.noted ? 'note' : 'needs a look'}: ${x.what}${x.items ? ': ' + x.items.join(', ') : ''}${!x.noted && x.allowable ? '. If you meant this: --allow "<reason>", or on a pull request comment sawbuck-allow: <reason>' : ''}`);
255
265
  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.`);
@@ -266,7 +276,13 @@ switch (cmd) {
266
276
  catch (e) { process.exit(e.status || 1); }
267
277
  break;
268
278
  }
269
- case 'context': { const o = contextBlock(repoRoot(), flags.has('--speak')); if (o) out(o); break; }
279
+ case 'context': {
280
+ // Nothing to show stays off stdout: an older attach runs this on every prompt and adds its stdout to the prompt.
281
+ // A person or an agent who ran it still learns why, on stderr.
282
+ const root = repoRoot(); const o = contextBlock(root, flags.has('--speak'));
283
+ if (o) out(o); else process.stderr.write(contextBlock(root, true) + '\n');
284
+ break;
285
+ }
270
286
  case 'frame': {
271
287
  // the prompt face of the contract: re-impress the hard rules (mandates, resolved through `extends`)
272
288
  // as one compact block at the live end of the context, so a deep or freshly compacted session
@@ -811,7 +827,7 @@ function doctorAll() {
811
827
  const r = regRead();
812
828
  if (!r.repos.length) { out('sawbuck: not attached anywhere.'); return; }
813
829
  out('sawbuck is attached to:');
814
- for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.repo}`);
830
+ for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.mode === 'user' ? 'every repo you open in Claude Code (hooks in ~/.claude/settings.json; the check runs where a promptwheel.config.json exists)' : x.repo}`);
815
831
  for (const repo of new Set(r.repos.map((x) => x.repo))) doctorRepo(repo);
816
832
  }
817
833