sawbuck 0.1.44 → 0.1.45

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 CHANGED
@@ -10,9 +10,9 @@ The contract has one property that holds all of it together: a verdict is **dete
10
10
 
11
11
  A corollary worth stating as an invariant: **the gate is defined only by in-repo, versioned config.** External systems (a dashboard, an issue tracker, a memory provider) may *report on* a verdict, but they must never *define* the guardrail. A guardrail that lives outside the repo is one the check cannot see in the diff, so it cannot be fingerprinted, so loosening it leaves no trace to catch as gamed. Memory (the input) is a pluggable socket; the gate (the output) stays in-repo and fingerprinted.
12
12
 
13
- > This contract covers the check. Sawbuck pairs it with the other half, **memory** (the relevant history for what you touch, plus the live debt), as the mirror idea: where the check is a standard others *conform to*, memory is a socket others *fill*. The default memory provider is @promptwheel/logbook, carried along so it works out of the box, but it is swappable; logbook is a default, not a requirement of the idea.
13
+ > This contract covers the check. Sawbuck pairs it with the other half, **memory**: the relevant history for what you touch, plus the live debt, from @promptwheel/logbook.
14
14
  >
15
- > Both halves nest with your folders, so policy and memory flow top-down and isolate across siblings. A parent `promptwheel.config.json` passes its guardrails down via `extends` (repos inherit the base gate; local metrics override by name), and memory inherits a parent folder's shared scope the same way (a repo sees itself and its ancestors, never a sibling). One model for both: the directory tree is the scope.
15
+ > Policy nests with your folders: a parent `promptwheel.config.json` passes its guardrails down via `extends` (repos inherit the base gate; local metrics override by name).
16
16
 
17
17
  ---
18
18
 
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # sawbuck
2
2
 
3
- **Sign your agent to a contract so it does what you want.**
3
+ **Agents follow written rules. Most repos have none.** Sawbuck turns what you keep telling your agent into written rules, and re-runs your tests to prove they held.
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 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.
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
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
 
@@ -13,7 +13,7 @@ In Claude Code, install the plugin and keep working:
13
13
  /plugin install sawbuck-reins@sawbuck
14
14
  ```
15
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.
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 checks every turn.
17
17
 
18
18
  Or from the command line:
19
19
 
@@ -21,10 +21,10 @@ Or from the command line:
21
21
  # 1 · try it (no install, no commitment): verify your latest change right now
22
22
  npx sawbuck check
23
23
 
24
- # 2 · sign this repo: the written rule, your tests, protected paths, CI
24
+ # 2 · give this repo its rules: the written rule, your tests, protected paths
25
25
  npm i -g sawbuck
26
- sawbuck contract
27
- sawbuck attach --claude # then watch every Claude Code turn
26
+ sawbuck contract # --full adds supply-chain guards, a repo lock and a CI check
27
+ sawbuck attach # check every Claude Code turn
28
28
  ```
29
29
 
30
30
  **Zero-config:** it auto-detects your test command, so there is nothing to write to start.
@@ -54,35 +54,36 @@ Exit codes: `0` verified · `1` regression · `2` gamed · `3` inconclusive · `
54
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.
55
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.
56
56
  - **Rides on, does not replace.** Sawbuck is the verify + memory layer. Your agent and your loop stay yours.
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.
57
+ - **Clean on and off.** `attach` 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.
58
58
 
59
59
  ## Commands
60
60
 
61
61
  | command | what |
62
62
  |---|---|
63
- | `sawbuck contract` | one command to sign a repo: keeps your contract, adds tests and tripwires if there are none, protected paths, the supply-chain guards, and the architecture lock. `--dry-run` previews it. |
63
+ | `sawbuck contract` | gives a repo its rules: keeps what is there, adds your tests if there are none, protected paths, and the written rule. `--full` adds supply-chain guards, a repo lock and a CI check. `--dry-run` previews |
64
64
  | `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 |
65
- | `sawbuck attach [--claude \| --user]` | enforce it (git post-commit / project Claude hooks / user-level for every project) |
66
- | `sawbuck detach [--claude \| --user]` | remove it; no flag reverses whatever is attached here |
65
+ | `sawbuck attach [--user]` | check every Claude Code turn, here or in every repo |
66
+ | `sawbuck detach [--user]` | remove it; no flag reverses whatever is attached here |
67
+ | `/sawbuck-reins:audit` | finds rules in your agent files that can pull against each other, and proposes which wins. No history needed |
67
68
  | `/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 |
68
69
  | `/sawbuck-reins:shape` | what you push on, what the agent did, and the gap between them, as a card |
69
- | `sawbuck status` · `sawbuck log` | the inventory (caught over time + debt now + armed state), in one view; and the recent per-check feed |
70
+ | `sawbuck status` | the inventory (caught over time + debt now + armed state), in one view; `--log` for the recent per-check feed |
70
71
  | `sawbuck debt` | the whole-repo live suppression debt: what is still silenced in HEAD now, disabled tests first, heaviest files ranked |
71
72
  | `sawbuck context` | the ranked relevant history for the current change |
72
- | `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
73
+ | `sawbuck doctor` · `detach --verify` | check the attachment resolves; prove a clean removal |
73
74
  | `sawbuck statusline` | a one-line status-line segment (🐴 watching / N caught) |
74
75
 
75
- ## Give your agent the contract
76
+ ## Give your agent the rule
76
77
 
77
- `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:
78
+ `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` 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:
78
79
 
79
- This repo is under a Sawbuck contract: a change is done only when the win survives verification
80
+ In this repo a change is done only when the win survives verification
80
81
  from the source alone. Before finishing, run `sawbuck check`. If it reads `gamed`, the green came
81
82
  from editing a test, config, or grader, not the source, so undo that and earn it in the source.
82
83
  Run `sawbuck context` first to see what broke in these files before (reverts, weakened tests,
83
84
  fragile spots). Never skip, weaken, or delete a test to go green.
84
85
 
85
- **After you sign.** A new contract 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>"`.
86
+ **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>"`.
86
87
 
87
88
  **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
 
package/agent-block.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  // Write (or refresh) the agent-orienting contract block so a coding agent (or a loop not wired to the
4
- // `attach --claude` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
4
+ // `attach` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
5
5
  // CLAUDE.md / .cursorrules when present. Idempotent + safe: only the sentinel-delimited block is
6
6
  // touched. `--no-agent-block` skips it.
7
7
  export function writeAgentBlock(repo) {
8
8
  const START = '<!-- sawbuck:contract -->', END = '<!-- /sawbuck:contract -->';
9
- const block = `${START}\n## Sawbuck contract\n\nThis repo is under a Sawbuck contract: a change is done only when the win survives verification from the source alone. Before finishing, run \`npx -y sawbuck check\`. If it reads \`gamed\`, the green came from editing a test, config, or grader, not the source, so undo that and earn it in the source. Run \`npx -y sawbuck context\` after your first edit to see what broke in these files before (reverts, weakened tests, fragile spots). Never skip, weaken, or delete a test to go green.\n${END}\n`;
9
+ const block = `${START}\n## Sawbuck rules\n\nIn this repo a change is done only when the win survives verification from the source alone. Before finishing, run \`npx -y sawbuck check\`. If it reads \`gamed\`, the green came from editing a test, config, or grader, not the source, so undo that and earn it in the source. Run \`npx -y sawbuck context\` after your first edit to see what broke in these files before (reverts, weakened tests, fragile spots). Never skip, weaken, or delete a test to go green.\n${END}\n`;
10
10
  const targets = ['AGENTS.md', ...['CLAUDE.md', '.cursorrules'].filter((f) => existsSync(join(repo, f)))];
11
11
  const done = [];
12
12
  for (const f of targets) {
package/contract-cmd.mjs CHANGED
@@ -1,8 +1,8 @@
1
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.
2
+ // sawbuck contract: give a repo its first rules with one command. Keeps whatever is already here, adds your tests if
3
+ // there is none, the protected paths, and the written rule, which is what changes the agent (the pressure bench: 11 of
4
+ // 20 to 0 of 20). --full adds the supply-chain guards, the repo lock and a CI check. Zero-LLM. Re-running changes
5
+ // nothing. Protected product code (auth, billing) is suggested, never added: freezing those is a person's decision.
6
6
  import { ensureExcluded } from './exclude.mjs';
7
7
  import { writeAgentBlock } from './agent-block.mjs';
8
8
  import { execFileSync } from 'node:child_process';
@@ -16,7 +16,7 @@ const HERE = dirname(fileURLToPath(import.meta.url));
16
16
  const repo = process.argv[2] || process.cwd();
17
17
  const dry = process.argv.includes('--dry-run');
18
18
  const strict = process.argv.includes('--strict');
19
- const noCi = process.argv.includes('--no-ci');
19
+ const full = process.argv.includes('--full') || strict;
20
20
  // a new contract starts by watching: everyday change shows as a note. Protected paths, install scripts and sources
21
21
  // outside the registry still fail from day one. --strict holds them all, and an existing contract is left as it is.
22
22
  const REPORT = new Set(['dependency_entries', 'lock_drift', 'pins_changed', 'unpinned_execution', 'install_policy_gaps']);
@@ -49,29 +49,35 @@ try {
49
49
  run([PW, 'init', '--preset', 'supply-chain', '--no-agent-block'], scratch);
50
50
  const preset = JSON.parse(readFileSync(join(scratch, 'promptwheel.config.json'), 'utf8'));
51
51
  const had = existsSync(cfgPath);
52
+ // without --full, the paths that exist only for the lock and the scanners do not apply
53
+ const mandates = preset.mandates.filter((m) => full || !JSON.stringify(m.paths || []).match(/packs|promptwheel\.lock/));
52
54
  if (dry) {
53
- 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`);
55
+ process.stdout.write(`sawbuck contract would ${had ? 'add to' : 'write'} ${cfgPath}: ${mandates.length} protected paths${full ? `, ${preset.metrics.length} guards, the scanners under .promptwheel/packs/, and promptwheel.lock.json` : ''}.\n`);
54
56
  } else {
55
57
  if (!had) run([join(HERE, 'init.mjs'), '-y', '--no-investigate'], repo);
56
58
  const cfg = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, 'utf8')) : { repeat: 1, metrics: [] };
57
59
  const before = { m: (cfg.mandates || []).length, g: (cfg.metrics || []).length };
58
60
  const names = (l) => new Set((l || []).map((x) => x && x.name));
59
61
  const hasM = names(cfg.mandates), hasG = names(cfg.metrics);
60
- cfg.mandates = [...(cfg.mandates || []), ...preset.mandates.filter((m) => !hasM.has(m.name))];
61
- cfg.metrics = [...(cfg.metrics || []), ...preset.metrics.filter((m) => !hasG.has(m.name)).map((m) => (!strict && REPORT.has(m.name) ? { ...m, report: true } : m))];
62
- cfg.evaluatorPaths = [...new Set([...(cfg.evaluatorPaths || []), ...(preset.evaluatorPaths || [])])];
62
+ cfg.mandates = [...(cfg.mandates || []), ...mandates.filter((m) => !hasM.has(m.name))];
63
+ if (full) {
64
+ cfg.metrics = [...(cfg.metrics || []), ...preset.metrics.filter((m) => !hasG.has(m.name)).map((m) => (!strict && REPORT.has(m.name) ? { ...m, report: true } : m))];
65
+ cfg.evaluatorPaths = [...new Set([...(cfg.evaluatorPaths || []), ...(preset.evaluatorPaths || [])])];
66
+ }
63
67
  cfg.allowTrailer = false;
64
68
  writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
65
69
  const pack = join(repo, '.promptwheel', 'packs', 'supply-chain');
66
- mkdirSync(pack, { recursive: true });
67
- cpSync(join(scratch, '.promptwheel', 'packs', 'supply-chain'), pack, { recursive: true, filter: (f) => !/(README\.md|pack\.json)$/.test(f) });
68
- // the gate measures in clean worktrees, so the scanners must reach git. A repo attached earlier, or one that
69
- // ignores .promptwheel/ for the outcome record, would hide them and every guard would read unmeasurable.
70
- for (const f of [join(repo, '.gitignore'), resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8' }).trim())]) {
71
- if (!existsSync(f)) continue;
72
- const text = readFileSync(f, 'utf8');
73
- const next = text.replace(/^\.promptwheel\/?[ \t]*$/gm, '.promptwheel/*\n!.promptwheel/packs/');
74
- if (next !== text) writeFileSync(f, next);
70
+ if (full) {
71
+ mkdirSync(pack, { recursive: true });
72
+ cpSync(join(scratch, '.promptwheel', 'packs', 'supply-chain'), pack, { recursive: true, filter: (f) => !/(README\.md|pack\.json)$/.test(f) });
73
+ // the gate measures in clean worktrees, so the scanners must reach git. A repo attached earlier, or one that
74
+ // ignores .promptwheel/ for the outcome record, would hide them and every guard would read unmeasurable.
75
+ for (const f of [join(repo, '.gitignore'), resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', 'info/exclude'], { encoding: 'utf8' }).trim())]) {
76
+ if (!existsSync(f)) continue;
77
+ const text = readFileSync(f, 'utf8');
78
+ const next = text.replace(/^\.promptwheel\/?[ \t]*$/gm, '.promptwheel/*\n!.promptwheel/packs/');
79
+ if (next !== text) writeFileSync(f, next);
80
+ }
75
81
  }
76
82
  ensureExcluded(repo);
77
83
  // the written rule is what changes the agent (the pressure bench: 11 of 20 to 0 of 20), so every signing writes it
@@ -82,11 +88,14 @@ try {
82
88
  let remote = ''; try { remote = execFileSync('git', ['-C', repo, 'remote', 'get-url', 'origin'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {}
83
89
  const realTests = !(cfg.metrics || []).some((m) => /set your test command/.test(String(m.cmd)));
84
90
  let ci = '';
85
- if (!noCi && /github\.com/.test(remote) && realTests && !existsSync(wf)) { mkdirSync(dirname(wf), { recursive: true }); writeFileSync(wf, workflow(existsSync(join(repo, 'package-lock.json')))); ci = wf; }
91
+ if (full && /github\.com/.test(remote) && realTests && !existsSync(wf)) { mkdirSync(dirname(wf), { recursive: true }); writeFileSync(wf, workflow(existsSync(join(repo, 'package-lock.json')))); ci = wf; }
86
92
  const locked = existsSync(join(repo, 'promptwheel.lock.json'));
87
- if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
93
+ if (full && !locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
88
94
  const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
89
- 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${strict ? '' : ' New dependencies and structure changes show as notes for now; protected paths and install scripts block. --strict blocks them all.\n'}${ci ? ' Added .github/workflows/sawbuck.yml, so pull requests are checked in CI. Commit it with the contract.\n' : ''}` : 'sawbuck: already signed, nothing changed.\n');
95
+ const fresh = added || (full && !locked);
96
+ process.stdout.write(!fresh ? 'sawbuck: already signed, nothing changed.\n' : full
97
+ ? `sawbuck: signed. ${cfg.mandates.length} protected paths, ${cfg.metrics.length} guards, and the shape of this repo in promptwheel.lock.json.\n${strict ? '' : ' New dependencies and structure changes show as notes for now; protected paths and install scripts block. --strict blocks them all.\n'}${ci ? ' Added .github/workflows/sawbuck.yml, so pull requests are checked in CI. Commit it with the rest.\n' : ''}`
98
+ : `sawbuck: signed. ${cfg.mandates.length} protected paths, and the rule in AGENTS.md.\n --full adds supply-chain guards, a repo lock and a CI check.\n`);
90
99
  }
91
100
  } finally { rmSync(scratch, { recursive: true, force: true }); }
92
101
 
@@ -96,4 +105,4 @@ try { files = execFileSync('git', ['-C', repo, 'ls-files'], { encoding: 'utf8',
96
105
  const seen = new Set();
97
106
  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]); }
98
107
  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`);
99
- if (!dry) process.stdout.write('\n Commit promptwheel.config.json, promptwheel.lock.json, AGENTS.md and .promptwheel/packs/. Then sawbuck attach --claude arms it.\n');
108
+ if (!dry) process.stdout.write(`\n Commit promptwheel.config.json and AGENTS.md${full ? ', promptwheel.lock.json and .promptwheel/packs/' : ''}. Then sawbuck attach checks every turn.\n`);
package/contract.mjs CHANGED
@@ -122,7 +122,7 @@ export function frameBlock(repo) {
122
122
  const mandates = resolvedMandates(repo);
123
123
  if (!mandates.length) return '';
124
124
  const lines = mandates.map((m) => ` ${m.paths.filter((s) => typeof s === 'string' && s.trim()).join(' · ')}${m.reason ? ` (${String(m.reason).slice(0, 160)})` : ''}`);
125
- return '▣ contract (sawbuck) · hard rules in force this session, enforced by the gate on every turn:\n protected, do not touch:\n' + lines.join('\n');
125
+ return '▣ rules (sawbuck) · checked on every turn:\n protected, do not touch:\n' + lines.join('\n');
126
126
  }
127
127
 
128
128
  // the repo's architecture lock, if the supply-chain preset is installed: structure the lock has not seen, and a
package/debt.mjs CHANGED
@@ -18,15 +18,15 @@ const failRaw = flag('--fail-on', null); // undocumented | test | any | <N>; bar
18
18
  const failMode = failRaw === true ? 'undocumented' : (failRaw ? String(failRaw).toLowerCase() : null);
19
19
  const asJson = process.argv.includes('--json');
20
20
 
21
- // events give fragility + fall-back dates (the memory provider's recall facts). Best-effort.
21
+ // events give fragility + fall-back dates (Logbook's facts). Best-effort.
22
22
  const events = memEvents(repo);
23
23
 
24
24
  // the live suppression ledger via the memory adapter (the Memory Contract's `debt` capability). Each
25
25
  // item arrives classified (documented/testSkip) and with its line content and blame date (`since`),
26
- // so this file no longer imports the provider's pattern or gating. null = the provider FAILED
26
+ // so this file does not import Logbook's pattern or gating. null = Logbook FAILED
27
27
  // (unmeasurable is NOT clean, refuse and exit nonzero); [] = a genuinely clean tree.
28
28
  const live = memDebt(repo);
29
- if (live === null) { console.error('sawbuck debt: could not read the suppression ledger (the memory provider failed)'); process.exit(1); }
29
+ if (live === null) { console.error('sawbuck debt: could not read the suppression ledger (Logbook failed)'); process.exit(1); }
30
30
  const bare = live.filter((it) => !it.documented && it.content).length;
31
31
 
32
32
  // --since REF: the DELTA. Suppressions in HEAD but not at REF = debt this branch/PR adds.
package/exclude.mjs CHANGED
@@ -5,7 +5,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
5
  import { resolve, dirname } from 'node:path';
6
6
  const sh = (r, ...a) => { try { return execFileSync('git', ['-C', r, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
7
7
 
8
- export const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'annotation-reviews.jsonl', 'decision-cards.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
8
+ export const RECORD_DIRS = ['.sawbuck/', '.promptwheel/*', '!.promptwheel/packs/', 'events.jsonl', 'annotations.jsonl', 'LOGBOOK.md', 'JOURNEY.md'];
9
9
 
10
10
  // everything the block hides, under any attach mode
11
11
  export const EXCLUDED = [...RECORD_DIRS, '.claude/settings.local.json'];
package/memory.mjs CHANGED
@@ -1,74 +1,9 @@
1
- // memory.mjs, Sawbuck's memory adapter: the single seam between the glue and the memory provider(s).
2
- // It speaks the Memory Contract (recall / debt / record) over a provider's CLI, so no other glue file
3
- // imports a provider's internals. Zero-dep.
4
- //
5
- // Providers: SAWBUCK_MEMORY is an ordered, comma-separated list (default "logbook"). An entry is
6
- // either the built-in "logbook" (the reference provider, adapted to its native query/audit/annotate
7
- // verbs) or another provider that speaks the clean contract verbs: `recall --file P --json`,
8
- // `debt [--since REF] --json`, `record --ref SHA --summary S [--side X] [--evidence-file F] --by WHO`.
9
- // The shipped reference non-git provider is "obsidian" (providers/obsidian.mjs); point at any other
10
- // with SAWBUCK_MEMORY_<NAME>_BIN or a path entry.
11
- //
12
- // Degradation is explicit: a read returns null when its PRIMARY (git-truth) source failed, [] when it
13
- // ran clean. A provider that does not support a capability signals it by exiting nonzero (we skip it),
14
- // never by printing []. git-truth stays authoritative; other providers add supplementary notes.
15
-
1
+ // memory.mjs, Sawbuck's adapter to Logbook, the one place that calls it: history, live debt, and a note on a catch. Zero-dep.
2
+ // A read returns null when Logbook failed, [] when it ran clean, so unmeasurable never reads as clean.
16
3
  import { execFileSync } from 'node:child_process';
17
- import { existsSync } from 'node:fs';
18
4
  import { LB } from './bins.mjs';
19
- import { join, dirname, relative, resolve, basename, sep } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
21
- import { homedir } from 'node:os';
22
-
23
- const HERE = dirname(fileURLToPath(import.meta.url));
24
-
25
- const envKey = (name) => 'SAWBUCK_MEMORY_' + name.toUpperCase().replace(/[^A-Z0-9]/g, '_') + '_BIN';
26
- // the ordered provider list. Each: { name, bin, dialect: 'logbook' | 'contract' }.
27
- const SHIPPED = new Set(['obsidian', 'mem0', 'local', 'claude']); // reference contract providers that ship in providers/
28
- export function providers() {
29
- const spec = (process.env.SAWBUCK_MEMORY || 'logbook').split(',').map((s) => s.trim()).filter(Boolean);
30
- return spec.map((name) => {
31
- if (name === 'logbook') return { name, bin: LB, dialect: 'logbook' };
32
- if (SHIPPED.has(name)) return { name, bin: process.env[envKey(name)] || join(HERE, 'providers', name + '.mjs'), dialect: 'contract' };
33
- return { name, bin: process.env[envKey(name)] || name, dialect: 'contract' }; // a bin path, or a name with an env-set bin
34
- });
35
- }
36
-
37
- // scopeChain(): the folder-based scope chain for a repo, most-specific first: the repo, then each
38
- // ancestor up to the memory root, then the root ("" = the shared/global scope). ISOLATION BY DEFAULT:
39
- // with no configured root the chain is just the leaf, so a repo sees only itself and never a sibling.
40
- // A root is SAWBUCK_MEMORY_ROOT, or the nearest ancestor holding a `.sawbuck-root` marker; with one,
41
- // a repo also inherits its ancestors' shared memory. Ids are paths relative to the root (forward
42
- // slashes), so sibling repos with the same basename never collide and never leak into each other.
43
- export function scopeChain(repo) {
44
- const start = resolve(repo);
45
- let root = process.env.SAWBUCK_MEMORY_ROOT ? resolve(process.env.SAWBUCK_MEMORY_ROOT) : null;
46
- if (!root) {
47
- let d = start; const stop = resolve(homedir());
48
- for (let i = 0; i < 64; i++) {
49
- if (existsSync(join(d, '.sawbuck-root'))) { root = d; break; }
50
- const up = dirname(d);
51
- if (up === d || d === stop) break;
52
- d = up;
53
- }
54
- }
55
- if (root && (start === root || start.startsWith(root + sep))) {
56
- const idOf = (dir) => relative(root, dir).split(sep).join('/'); // "" for the root itself
57
- const chain = [];
58
- let d = start;
59
- for (let i = 0; i < 64; i++) {
60
- chain.push(idOf(d));
61
- if (d === root) break;
62
- const up = dirname(d);
63
- if (up === d) break;
64
- d = up;
65
- }
66
- return chain; // leaf-first, ending with "" (root = shared/global)
67
- }
68
- return [basename(start) || '.']; // no root in play: an isolated leaf, no inheritance
69
- }
70
5
 
71
- // run a provider bin; null on a real failure / unsupported-capability (nonzero exit), stdout otherwise.
6
+ // null on a real failure, stdout otherwise
72
7
  function run(bin, repo, args, maxBuffer = 512 * 1024 * 1024) {
73
8
  try { return execFileSync(process.execPath, [bin, ...args], { cwd: repo, encoding: 'utf8', maxBuffer, stdio: ['ignore', 'pipe', 'ignore'] }); }
74
9
  catch { return null; }
@@ -80,7 +15,7 @@ function parseArray(out) {
80
15
  }
81
16
 
82
17
  // --- debt sub-classification (Sawbuck policy; applied once so the ledger and the per-turn surface
83
- // agree). Exported so the conformance checker can reuse them.
18
+ // agree).
84
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());
85
20
  export const isDocumented = (c) => {
86
21
  c = String(c || '');
@@ -100,101 +35,26 @@ const classify = (r, source) => ({
100
35
  source,
101
36
  });
102
37
 
103
- // events(): the raw git event record (the ranking input), from the logbook-dialect provider only
104
- // (events with revert/fix/suppression semantics are git-truth; a note provider does not produce them).
105
- // [] when there is no such provider or it fails, exactly as "no history".
38
+ // events(): the raw git event record, the ranking input. [] on failure, exactly as "no history".
106
39
  export function events(repo) {
107
- const lb = providers().find((p) => p.dialect === 'logbook');
108
- if (!lb) return [];
109
- const out = run(lb.bin, repo, ['query', '--json'], 256 * 1024 * 1024);
40
+ const out = run(LB, repo, ['query', '--json', '--limit', '100000'], 256 * 1024 * 1024);
110
41
  if (out === null) return [];
111
42
  return out.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
112
43
  }
113
44
 
114
- // the per-provider debt call, dialect-aware.
115
- function debtRaw(p, repo, { paths, ref }) {
116
- const args = p.dialect === 'logbook'
117
- ? ['audit', '--json', ...(ref ? ['--ref', ref] : []), ...((paths || []).flatMap((f) => ['--file', f]))]
118
- : ['debt', '--json', ...(ref ? ['--since', ref] : []), ...((paths || []).flatMap((f) => ['--file', f]))];
119
- return run(p.bin, repo, args);
120
- }
121
-
122
- // debt(): live suppressions still in the tree, each classified. Fans out across providers that SUPPORT
123
- // debt (git-truth); a provider that does not implement it exits nonzero and is skipped. Returns null
124
- // only when NO provider answered (unmeasurable, not clean); [] when a provider ran and the tree is
125
- // clean. Default (logbook-only) is byte-identical to the single-provider behavior.
45
+ // debt(): live suppressions still in the tree, each classified. null when Logbook could not answer.
126
46
  export function debt(repo, { paths = null, ref = null } = {}) {
127
- let answered = false;
128
- const rows = [];
129
- for (const p of providers()) {
130
- const out = debtRaw(p, repo, { paths, ref });
131
- if (out === null) continue; // failed or unsupported -> skip
132
- answered = true;
133
- for (const r of parseArray(out)) rows.push(classify(r, p.name));
134
- }
135
- return answered ? rows : null;
136
- }
137
-
138
- // recall(): supplementary relevant NOTES for the touched paths, from providers that produce them
139
- // (contract-dialect; logbook's own history is surfaced via rank, not here). Each is a Finding of kind
140
- // note/decision, source-tagged. [] when nothing relevant. git-truth is never diluted by these.
141
- const normText = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[^\w ]+/g, '').trim();
142
- export function recall(repo, paths) {
143
- const scopeArgs = scopeChain(repo).flatMap((id) => ['--scope', id]); // leaf-first: self + ancestors, never siblings
144
- const raw = [];
145
- for (const p of providers()) {
146
- if (p.dialect !== 'contract') continue;
147
- const r = run(p.bin, repo, ['recall', '--json', ...((paths || []).flatMap((f) => ['--file', f])), ...scopeArgs]);
148
- if (r === null) continue;
149
- for (const it of parseArray(r)) raw.push({ kind: String(it.kind || 'note'), path: it.path ?? null, ref: it.ref ?? null, date: it.date ?? null, summary: String(it.summary || '').slice(0, 280), source: p.name, scope: it.scope ?? null, score: typeof it.score === 'number' ? it.score : null });
150
- }
151
- // Cross-provider dedup + unified ordering. The same note surfaced by two providers collapses to one
152
- // (keep the higher score; record that both knew it). Ordering is best-effort by the providers' own
153
- // relevance scores: these are SUPPLEMENTARY leads, never git-truth, so a fuzzy order is fine.
154
- const byKey = new Map();
155
- for (const f of raw) {
156
- const k = (f.path ? normText(f.path) + '\u0000' : '') + normText(f.summary);
157
- const prev = byKey.get(k);
158
- if (!prev) { byKey.set(k, f); continue; }
159
- const keep = (f.score ?? 0) > (prev.score ?? 0) ? f : prev, other = keep === f ? prev : f;
160
- if (other.source && !String(keep.source).split('+').includes(other.source)) keep.source = `${keep.source}+${other.source}`;
161
- byKey.set(k, keep);
162
- }
163
- return [...byKey.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, 8);
164
- }
165
-
166
- // conform(): read-only conformance probe of a contract-dialect provider bin. Checks recall returns a
167
- // JSON array (or errors cleanly) and that debt either returns an array (git-truth) or signals
168
- // unsupported by exiting nonzero (never a bare []). Does not write, so record is validated elsewhere
169
- // (the record fan-out). Returns a report the `memory-conformance` command prints.
170
- export function conform(bin, repo, probePath = 'README.md') {
171
- const probe = (args) => { try { return { out: execFileSync(process.execPath, [bin, ...args], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }), code: 0 }; } catch (e) { return { out: (e.stdout || '').toString(), code: e.status || 1 }; } };
172
- const isArray = (s) => { s = (s || '').trim(); if (!s.startsWith('[')) return false; try { return Array.isArray(JSON.parse(s)); } catch { return false; } };
173
- const r = probe(['recall', '--json', '--file', probePath]);
174
- const recall = r.code !== 0 ? 'error (nonzero exit on recall)' : (isArray(r.out) ? 'ok (Finding[])' : 'bad shape (recall must print a JSON array)');
175
- const d = probe(['debt', '--json']);
176
- const debt = d.code !== 0 ? 'unsupported (exits nonzero, ok for a non-git provider)' : (isArray(d.out) ? 'supported (Suppression[])' : 'bad shape (debt must print a JSON array or exit nonzero)');
177
- const pass = /^ok/.test(recall) && !/bad shape/.test(debt);
178
- return { bin, recall, debt, pass };
47
+ const out = run(LB, repo, ['audit', '--json', ...(ref ? ['--ref', ref] : []), ...((paths || []).flatMap((f) => ['--file', f]))]);
48
+ return out === null ? null : parseArray(out).map((r) => classify(r, 'logbook'));
179
49
  }
180
50
 
181
- // record(): persist a machine finding to every WRITABLE provider (the write-back). Best-effort; never
182
- // throws into the caller. Returns true if at least one provider accepted it.
51
+ // record(): keep a machine finding as an annotation. Best-effort, never throws into the caller.
183
52
  export function record(repo, { sha, reason, side, evidenceFile, by = 'sawbuck' }) {
184
53
  const text = String(reason).slice(0, 280);
185
- const leaf = scopeChain(repo)[0]; // a finding belongs to the most-specific scope that produced it
186
- let ok = false;
187
- for (const p of providers()) {
188
- const exec = (a) => execFileSync(process.execPath, [p.bin, ...a], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
189
- try {
190
- if (p.dialect === 'logbook') {
191
- const full = ['annotate', sha, text, ...(side ? ['--side', side] : []), ...(evidenceFile ? ['--evidence-file', evidenceFile] : []), '--by', by];
192
- try { exec(full); } catch { exec(['annotate', sha, text, '--by', by]); }
193
- } else {
194
- exec(['record', '--ref', sha, '--summary', text, ...(side ? ['--side', side] : []), ...(evidenceFile ? ['--evidence-file', evidenceFile] : []), '--scope', leaf, '--by', by]);
195
- }
196
- ok = true;
197
- } catch { /* this provider declined; others may still accept */ }
198
- }
199
- return ok;
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; }
200
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
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 untested.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -33,7 +33,7 @@
33
33
  "sawbuck": "sawbuck.mjs"
34
34
  },
35
35
  "dependencies": {
36
- "@promptwheel/logbook": "0.9.10",
36
+ "@promptwheel/logbook": "0.11.0",
37
37
  "promptwheel": "0.7.16"
38
38
  },
39
39
  "files": [
@@ -43,7 +43,6 @@
43
43
  "rules.mjs",
44
44
  "history.mjs",
45
45
  "shape.mjs",
46
- "exposure.mjs",
47
46
  "agent-block.mjs",
48
47
  "exclude.mjs",
49
48
  "repo-state.mjs",
@@ -52,16 +51,11 @@
52
51
  "contract.mjs",
53
52
  "init.mjs",
54
53
  "reflect.mjs",
55
- "dispatch.mjs",
56
54
  "sawbuck-hook.mjs",
57
55
  "rank.mjs",
58
56
  "debt.mjs",
59
57
  "status.mjs",
60
58
  "memory.mjs",
61
- "providers/obsidian.mjs",
62
- "providers/mem0.mjs",
63
- "providers/local.mjs",
64
- "providers/claude.mjs",
65
59
  "README.md",
66
60
  "CONTRACT.md"
67
61
  ]
package/rank.mjs CHANGED
@@ -28,8 +28,8 @@ let paths = multi('--file');
28
28
  if (!paths.length) paths = git('diff', '--name-only', base, head).split('\n').filter(Boolean);
29
29
  if (!paths.length) { console.log(asJson ? '[]' : 'rank: no paths to rank'); process.exit(0); }
30
30
 
31
- // the full event record (the memory provider's recall facts), ranked client-side below
32
- // (the provider's --file does not narrow the record, so we pull it once and filter by path here).
31
+ // the full event record (Logbook's facts), ranked client-side below
32
+ // (Logbook's --file does not narrow the record, so we pull it once and filter by path here).
33
33
  const EVENTS = memEvents(repo);
34
34
 
35
35
  const resolveRename = (f) => f.replace(/\{[^}]*=>\s*([^}]*)\}/g, '$1').replace(/\/{2,}/g, '/');
@@ -83,9 +83,9 @@ const DEBT_MIN = Math.max(1, +(process.env.SAWBUCK_DEBT_MIN) || 6);
83
83
  // Still-live filter + ambient debt, via the memory adapter (the Memory Contract's `debt`
84
84
  // capability), scoped to the touched paths so the per-turn hook stays cheap. A suppression on a path
85
85
  // now CLEAN of suppressions is churn, not current risk (the study: AutoMapper carries 4 live
86
- // `[Ignore]` vs ~158/repo across C# history). The adapter runs the provider's own detection + gating
87
- // and returns classified suppressions, so the glue never imports the provider's pattern. null = the
88
- // provider failed -> treat all as live (do not penalize); [] = genuinely clean. --raw turns it off.
86
+ // `[Ignore]` vs ~158/repo across C# history). The adapter runs Logbook's own detection + gating
87
+ // and returns classified suppressions, so the glue never imports its pattern. null = the
88
+ // Logbook failed -> treat all as live (do not penalize); [] = genuinely clean. --raw turns it off.
89
89
  let liveSet = null;
90
90
  const liveDebt = new Map(); // path -> { n, skips, bare }: live suppressions, for ambient surfacing
91
91
  if (!raw && paths.length) {
package/reflect.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // Run the check (the promptwheel engine) on the latest commit; on a gamed or
5
5
  // regression result, deterministic ground truth, record a finding in the
6
6
  // memory (the logbook engine) on the touched source paths, and tick the results
7
- // record. Quiet by default; the dispatch surfaces catches on a cadence.
7
+ // record. Quiet by default; the Stop hook says a fresh catch once.
8
8
  //
9
9
  // Zero-dep, Node ESM. Orchestrates the engine CLIs behind a clean layer
10
10
  // boundary, never reaching into their source.
@@ -113,7 +113,7 @@ if (!repeatOfStanding) { if (kind === 'gamed') rec.gamed += 1; else rec.regressi
113
113
  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 };
114
114
  writeRec();
115
115
 
116
- // --- 5) quiet by default (dispatch surfaces on cadence); one line to stderr -
116
+ // --- 5) quiet by default; one line to stderr -
117
117
  process.stderr.write(`sawbuck: ${kind} recorded on ${evidenceFile}\n`);
118
118
 
119
119
  // --- the activity feed: one line per check so you can watch it work, verified
package/rules.mjs CHANGED
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
7
7
  import { readHistory, typedText } from './history.mjs';
8
8
 
9
9
  const MAX_LINES = 3000;
10
- 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;
10
+ 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
11
  const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
12
12
  const paths = (home) => ({ dir: join(home, '.sawbuck'), input: join(home, '.sawbuck', 'rule-input.txt') });
13
13
 
package/sawbuck-hook.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureExcluded } from './exclude.mjs';
3
3
  // sawbuck-hook.mjs: the Sawbuck Claude Code Stop-hook entry.
4
- // Each turn: if a new commit landed, run reflect on it; then render the dispatch
5
- // on a fresh catch OR every N turns (config). State in .sawbuck/state.json.
4
+ // Each turn: if a new commit landed, run reflect on it; then say so once on a fresh catch. State in .sawbuck/state.json.
6
5
  // Zero-dep. Usage (wired as a Stop hook): node sawbuck-hook.mjs --repo <path>
7
6
 
8
7
  import { execFileSync } from 'node:child_process';
@@ -32,7 +31,6 @@ const node = (f, a) => { try { return execFileSync(process.execPath, [f, ...a],
32
31
 
33
32
  let cfg = {};
34
33
  try { cfg = JSON.parse(readFileSync(join(repo, 'sawbuck.config.json'), 'utf8')); } catch {}
35
- const EVERY_TURNS = 5;
36
34
 
37
35
  const stDir = join(repo, '.sawbuck');
38
36
  const stPath = join(stDir, 'state.json');
@@ -132,7 +130,7 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
132
130
  const restore = Object.keys(before).length ? ' To put back what you overwrote: ' + Object.entries(before).map(([f, h]) => `restore ${f} to the content it had when the prompt began: git cat-file -p ${h} > ${f}`).join('; ') + '.' : '';
133
131
  const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
134
132
  const accept = committedHit ? ' To accept this change on purpose, change the rule in the contract or revert the path.' : '';
135
- const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach --claude again, which upgrades the hooks.)';
133
+ const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach again, which upgrades the hooks.)';
136
134
  // one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
137
135
  // a block must not replace a gamed verdict that is still standing.
138
136
  if (st.mandateCountedPrompt !== promptId) {
@@ -177,12 +175,10 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE && st.turnBaseline && st.turnBaselin
177
175
  }
178
176
  }
179
177
 
180
- // 4) otherwise, surface a catch on a fresh catch or on cadence (a flag; never blocks)
181
- const freshCatch = total > (st.shownTotal || 0);
182
- const cadenceHit = st.turns % EVERY_TURNS === 0;
183
- if (freshCatch || cadenceHit) {
184
- const out = node(join(HERE, 'dispatch.mjs'), ['--repo', repo]);
185
- if (out.trim()) process.stdout.write(out.endsWith('\n') ? out : out + '\n');
178
+ // 4) otherwise, say a fresh catch once (a flag; never blocks). The band and statusline carry the rest.
179
+ if (total > (st.shownTotal || 0)) {
180
+ const L = rec && rec.latest;
181
+ process.stdout.write(`🐴 Sawbuck caught a ${L && L.kind === 'gamed' ? 'gamed change' : 'problem'}${L && L.file ? ` in ${L.file}` : ''}${L && L.reason ? `: ${L.reason}` : ''}\n`);
186
182
  st.shownTotal = total;
187
183
  }
188
184
  saveState();