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 +2 -2
- package/README.md +17 -16
- package/agent-block.mjs +2 -2
- package/contract-cmd.mjs +31 -22
- package/contract.mjs +1 -1
- package/debt.mjs +3 -3
- package/exclude.mjs +1 -1
- package/memory.mjs +16 -156
- package/package.json +2 -8
- package/rank.mjs +5 -5
- package/reflect.mjs +2 -2
- package/rules.mjs +1 -1
- package/sawbuck-hook.mjs +6 -10
- package/sawbuck.mjs +49 -175
- package/shape.mjs +12 -51
- package/status.mjs +1 -1
- package/dispatch.mjs +0 -34
- package/exposure.mjs +0 -104
- package/providers/claude.mjs +0 -49
- package/providers/local.mjs +0 -55
- package/providers/mem0.mjs +0 -120
- package/providers/obsidian.mjs +0 -119
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
|
|
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
|
-
>
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
|
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 ·
|
|
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
|
|
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.**
|
|
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` |
|
|
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 [--
|
|
66
|
-
| `sawbuck detach [--
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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:
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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}: ${
|
|
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 || []), ...
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
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 '▣
|
|
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 (
|
|
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
|
|
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 (
|
|
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', '
|
|
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
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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).
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
128
|
-
|
|
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():
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
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.
|
|
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 (
|
|
32
|
-
// (
|
|
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
|
|
87
|
-
// and returns classified suppressions, so the glue never imports
|
|
88
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
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();
|