sawbuck 0.1.43 → 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 +20 -21
- package/agent-block.mjs +9 -16
- package/contract-cmd.mjs +32 -23
- package/contract.mjs +1 -1
- package/debt.mjs +3 -3
- package/exclude.mjs +16 -10
- package/memory.mjs +16 -156
- package/package.json +3 -8
- package/rank.mjs +5 -5
- package/reflect.mjs +2 -2
- package/repo-state.mjs +22 -0
- package/rules.mjs +1 -1
- package/sawbuck-hook.mjs +22 -37
- package/sawbuck.mjs +76 -223
- package/shape.mjs +35 -50
- package/status.mjs +6 -12
- package/steers.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/sawbuck.mjs
CHANGED
|
@@ -1,28 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// sawbuck, the CLI. Zero-dep.
|
|
3
|
-
// `attach` wires
|
|
4
|
-
//
|
|
5
|
-
// was. Isolation invariant: the repo never imports Sawbuck, and detach is a true
|
|
6
|
-
// inverse of attach. Rides two engines: promptwheel (the check) + @promptwheel/logbook.
|
|
3
|
+
// `attach` wires the Claude Code hooks into a repo; `detach` removes them and leaves the repo exactly as it was. The
|
|
4
|
+
// repo never imports Sawbuck. Rides two engines: promptwheel (the check) + @promptwheel/logbook.
|
|
7
5
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// line it added. detach reads the receipt and reverses exactly that, deleting
|
|
11
|
-
// what it created, stripping only its own lines from what it modified, touching
|
|
12
|
-
// nothing it did not touch. `trace` prints the footprint; `detach --verify`
|
|
13
|
-
// proves the repo came back clean; `doctor` checks the attachment still resolves.
|
|
6
|
+
// attach writes a receipt to .git/sawbuck/attach.json recording every file it created or modified. detach reads it and
|
|
7
|
+
// reverses exactly that; `detach --verify` proves the repo came back clean; `doctor` checks the attachment still resolves.
|
|
14
8
|
|
|
15
9
|
import { execFileSync } from 'node:child_process';
|
|
16
|
-
import { erasedBlock } from './rewrites.mjs';
|
|
10
|
+
import { erasedBlock, erasedCommits } from './rewrites.mjs';
|
|
17
11
|
import { steersReport } from './steers.mjs';
|
|
18
12
|
import { rulesCli } from './rules.mjs';
|
|
19
13
|
import { shapeCli } from './shape.mjs';
|
|
20
|
-
import { ensureExcluded, RECORD_DIRS } from './exclude.mjs';
|
|
14
|
+
import { ensureExcluded, clearExcluded, EXCLUDED, RECORD_DIRS } from './exclude.mjs';
|
|
15
|
+
import { repoState } from './repo-state.mjs';
|
|
21
16
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
|
|
22
17
|
import { basename, join, dirname, resolve } from 'node:path';
|
|
23
18
|
import { fileURLToPath } from 'node:url';
|
|
24
19
|
import { homedir } from 'node:os';
|
|
25
|
-
import { recall as memRecall, conform as memConform } from './memory.mjs';
|
|
26
20
|
import { PW } from './bins.mjs';
|
|
27
21
|
import { frameBlock, mandatesInForce, protectedDirty, blobAt, lockState } from './contract.mjs';
|
|
28
22
|
|
|
@@ -53,7 +47,7 @@ const argv = process.argv.slice(2);
|
|
|
53
47
|
const cmd = argv[0];
|
|
54
48
|
const rest = argv.slice(1);
|
|
55
49
|
const flags = new Set(rest.filter((a) => a.startsWith('--')));
|
|
56
|
-
// `-n N` (
|
|
50
|
+
// `-n N` (status --log) is a flag with a value, not a repo path.
|
|
57
51
|
const positional = rest.filter((a, i) => !a.startsWith('--') && a !== '-n' && rest[i - 1] !== '-n');
|
|
58
52
|
const repoArg = () => resolve(positional[0] || '.');
|
|
59
53
|
const dry = flags.has('--dry-run');
|
|
@@ -61,7 +55,7 @@ const dry = flags.has('--dry-run');
|
|
|
61
55
|
let version = '0.0.0';
|
|
62
56
|
try { version = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).version; } catch {}
|
|
63
57
|
|
|
64
|
-
const help = `sawbuck ${version}:
|
|
58
|
+
const help = `sawbuck ${version}: write down the rules your agent follows, and prove they held
|
|
65
59
|
|
|
66
60
|
Usage:
|
|
67
61
|
sawbuck check [repo] [--base R --head R] [--repeat N] [--allow "<reason>"] [--json]
|
|
@@ -70,42 +64,29 @@ Usage:
|
|
|
70
64
|
fastest way to see it work. Every promptwheel run flag passes through.
|
|
71
65
|
Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
|
|
72
66
|
sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
|
|
73
|
-
Customize the
|
|
74
|
-
git history shows actually breaks, plus a short
|
|
67
|
+
Customize the rules (promptwheel.config.json), with notes from what your
|
|
68
|
+
git history shows actually breaks, plus a short guide in AGENTS.md
|
|
75
69
|
(--no-agent-block to skip). Wizard in a terminal, non-interactive in a pipe/CI (or --yes).
|
|
76
|
-
sawbuck attach [repo]
|
|
77
|
-
|
|
78
|
-
--shared with --claude, write the tracked .claude/settings.json (team-shared, visible in git)
|
|
79
|
-
--user user-level Claude hooks that follow your cwd (attach once, every project)
|
|
80
|
-
--dry-run print the exact footprint it would leave, and write nothing
|
|
81
|
-
sawbuck detach [repo] [--claude | --user] [--all] [--verify] [--purge]
|
|
70
|
+
sawbuck attach [repo] Check every Claude Code turn in this repo. --user for every repo. --dry-run prints the footprint
|
|
71
|
+
sawbuck detach [repo] [--user] [--verify] [--purge]
|
|
82
72
|
Remove it and restore the repo (no flag: whatever is attached here); --verify
|
|
83
73
|
proves it came back clean; --purge also deletes the record (.sawbuck/)
|
|
84
74
|
sawbuck status [repo] The inventory: caught over time + debt accruing now + whether it is armed. --json
|
|
85
|
-
|
|
75
|
+
--log [-n N] for the recent checks, newest first
|
|
86
76
|
sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
|
|
87
77
|
The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
|
|
88
78
|
shows only what a branch adds; --fail-on exits nonzero (a CI gate)
|
|
89
79
|
sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
|
|
90
|
-
sawbuck
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
|
|
95
|
-
sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
|
|
96
|
-
sawbuck where List every repo Sawbuck is attached to
|
|
97
|
-
sawbuck contract Sign this repo: tests, tripwires, protected paths, and the architecture lock (--dry-run to preview)
|
|
80
|
+
sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck
|
|
81
|
+
sawbuck contract Give this repo its rules: protected paths, your tests (--dry-run to preview)
|
|
82
|
+
--full adds supply-chain guards, the repo lock and a CI check; --strict makes them block
|
|
98
83
|
sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
|
|
99
84
|
sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
|
|
100
85
|
sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
|
|
101
|
-
sawbuck shape --check F Draw your steering shape from the labels, keeping only the totals
|
|
86
|
+
sawbuck shape --check F Draw your steering shape from the labels, keeping only the totals (--png for an image)
|
|
102
87
|
sawbuck steers What you keep correcting, from the plugin's scratchpad on this machine (--json)
|
|
103
|
-
sawbuck statusline One-line segment for your status line (🐴 watching | N caught
|
|
88
|
+
sawbuck statusline One-line segment for your status line (🐴 watching | N caught)
|
|
104
89
|
|
|
105
|
-
sawbuck recall [repo] Related notes for your changes from extra memory providers
|
|
106
|
-
(SAWBUCK_MEMORY=logbook,obsidian + OBSIDIAN_VAULT), alongside context
|
|
107
|
-
sawbuck memory-conformance <bin>
|
|
108
|
-
Check a provider bin speaks the Memory Contract (recall/debt/record)
|
|
109
90
|
sawbuck --help | --version
|
|
110
91
|
|
|
111
92
|
Engines: promptwheel (the check) + @promptwheel/logbook (memory). Zero-LLM, offline, free.`;
|
|
@@ -124,9 +105,9 @@ const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
|
|
|
124
105
|
|
|
125
106
|
if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
|
|
126
107
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
127
|
-
out(flags.has('--all') ? help : `sawbuck ${version}:
|
|
108
|
+
out(flags.has('--all') ? help : `sawbuck ${version}: write down the rules your agent follows, and prove they held
|
|
128
109
|
|
|
129
|
-
sawbuck contract
|
|
110
|
+
sawbuck contract give this repo its rules: protected paths, your tests
|
|
130
111
|
sawbuck check verify your latest change right now
|
|
131
112
|
sawbuck status what it has caught here
|
|
132
113
|
|
|
@@ -137,8 +118,8 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
137
118
|
switch (cmd) {
|
|
138
119
|
case 'contract': {
|
|
139
120
|
const top = gitTop(repoArg());
|
|
140
|
-
if (!top) usage(`${repoArg()} is not a git repository (
|
|
141
|
-
try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => ['--dry-run', '--
|
|
121
|
+
if (!top) usage(`${repoArg()} is not a git repository (run it inside your project)`);
|
|
122
|
+
try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => ['--dry-run', '--full', '--strict'].includes(a))], { stdio: 'inherit' }); }
|
|
142
123
|
catch (e) { process.exit(e.status || 1); }
|
|
143
124
|
break;
|
|
144
125
|
}
|
|
@@ -149,7 +130,8 @@ switch (cmd) {
|
|
|
149
130
|
}
|
|
150
131
|
case 'status': {
|
|
151
132
|
// the inventory: one view unioning what the check has caught over time, what debt is accruing
|
|
152
|
-
// now, and whether it is armed here. The "show me everything" face.
|
|
133
|
+
// now, and whether it is armed here. The "show me everything" face. --log is the recent feed.
|
|
134
|
+
if (flags.has('--log')) { logReport(repoArg(), rest); break; }
|
|
153
135
|
try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoArg(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
|
|
154
136
|
catch (e) { process.exit(e.status || 1); }
|
|
155
137
|
break;
|
|
@@ -192,7 +174,7 @@ switch (cmd) {
|
|
|
192
174
|
if (rep.verdict === 'pass' && win) out(`🐴 ✓ verified: your ${where} change earned it. "${win.name}" improved, and the source carried the win (it survives a source-only re-run).`);
|
|
193
175
|
else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
|
|
194
176
|
else if (rep.verdict === 'gamed') out('🐴 🚩 gamed: a win here came from editing the test, config, or grader, not the source. It does NOT survive a source-only re-run, so it does not count.');
|
|
195
|
-
else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a
|
|
177
|
+
else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a rule in the contract was broken, however green the rest is.');
|
|
196
178
|
else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
|
|
197
179
|
else out('🐴 ≈ inconclusive: the check ran but could not measure your metric (it did not run in isolation). Point it at your real test command: `sawbuck init`, or set setup/linkDirs in promptwheel.config.json.');
|
|
198
180
|
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.');
|
|
@@ -200,25 +182,6 @@ switch (cmd) {
|
|
|
200
182
|
if (rep.gate && rep.gate.changed) out(`🐴 ⚠ the gate definition changed in this change (fingerprint ${rep.gate.fingerprint.base} -> ${rep.gate.fingerprint.head}): a metric, guard, evaluatorPath or mandate was added, removed or edited.`);
|
|
201
183
|
process.exit(code);
|
|
202
184
|
}
|
|
203
|
-
case 'log': {
|
|
204
|
-
// the event feed: a running log of what the check did, so you can watch it work.
|
|
205
|
-
const repo = repoArg();
|
|
206
|
-
let lines = [];
|
|
207
|
-
try { lines = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').split('\n').filter(Boolean); } catch {}
|
|
208
|
-
if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
|
|
209
|
-
const ni = rest.indexOf('-n');
|
|
210
|
-
const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
|
|
211
|
-
const icon = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
|
|
212
|
-
const recent = lines.slice(-n).reverse();
|
|
213
|
-
out(`sawbuck activity (last ${recent.length}):`);
|
|
214
|
-
for (const l of recent) {
|
|
215
|
-
try {
|
|
216
|
-
const e = JSON.parse(l);
|
|
217
|
-
out(` ${(e.at || '').slice(0, 16).replace('T', ' ')} ${icon[e.kind] || '·'} ${String(e.kind).padEnd(11)} ${e.file || ''}${e.reason ? ' ' + e.reason : ''}`);
|
|
218
|
-
} catch {}
|
|
219
|
-
}
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
185
|
case 'rank': {
|
|
223
186
|
try { execFileSync(process.execPath, [join(HERE, 'rank.mjs'), ...rest], { stdio: 'inherit' }); }
|
|
224
187
|
catch (e) { process.exit(e.status || 1); }
|
|
@@ -241,9 +204,10 @@ switch (cmd) {
|
|
|
241
204
|
break;
|
|
242
205
|
}
|
|
243
206
|
case 'turn': {
|
|
244
|
-
// the ONE per-prompt hook
|
|
245
|
-
//
|
|
246
|
-
//
|
|
207
|
+
// the ONE per-prompt hook. The agent gets the contract frame once a session and again after a compaction: one
|
|
208
|
+
// written statement held in every bench, so saying it on each prompt only spends context. The relevant history
|
|
209
|
+
// is on demand (`sawbuck context`), and per turn only with {"turn":{"context":true}} in sawbuck.config.json.
|
|
210
|
+
// Wired by `attach` on UserPromptSubmit and after a compaction.
|
|
247
211
|
// the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
|
|
248
212
|
// subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
|
|
249
213
|
const top = gitTop(repoArg());
|
|
@@ -258,8 +222,8 @@ switch (cmd) {
|
|
|
258
222
|
// a file still carrying the content the last violation flagged is never baselined; and the
|
|
259
223
|
// baseline's commit does not advance past a flagged commit until a clean stop or a commit the
|
|
260
224
|
// person made between turns that no longer carries the flagged content.
|
|
261
|
-
let hookEvent = '';
|
|
262
|
-
if (flags.has('--hook') && !process.stdin.isTTY) { try {
|
|
225
|
+
let hookEvent = '', session = '', framed = false;
|
|
226
|
+
if (flags.has('--hook') && !process.stdin.isTTY) { try { const h = JSON.parse(readFileSync(0, 'utf8') || '{}'); hookEvent = String(h.hook_event_name || ''); session = String(h.session_id || ''); } catch {} }
|
|
263
227
|
if (hookEvent === 'UserPromptSubmit' && top) {
|
|
264
228
|
try {
|
|
265
229
|
ensureExcluded(repo); // before the first write: under user-level hooks nothing else has done it yet
|
|
@@ -290,40 +254,19 @@ switch (cmd) {
|
|
|
290
254
|
st.turnBaseline = { at: now, sha: ((!moved || stillInHead) && pin && st.lastViolation.sha) || sha, files, lock: lockState(repo), rules: mandatesInForce(repo) };
|
|
291
255
|
}
|
|
292
256
|
}
|
|
257
|
+
framed = !!session && st.framedSession === session;
|
|
258
|
+
if (session) st.framedSession = session;
|
|
293
259
|
st.lastPromptAt = now; // every prompt, mandates or not: the walls key their block budgets on it when the harness sends no prompt_id
|
|
294
260
|
mkdirSync(stDir, { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
295
261
|
} catch { /* the frame never fails a prompt */ }
|
|
296
262
|
}
|
|
297
|
-
|
|
263
|
+
let history = false;
|
|
264
|
+
try { history = JSON.parse(readFileSync(join(repo, 'sawbuck.config.json'), 'utf8')).turn?.context === true; } catch {}
|
|
265
|
+
if (!history && top) erasedCommits(repo); // the ledger outlives the reflog whether or not it is printed
|
|
266
|
+
const o = [framed ? '' : frameBlock(repo), history ? contextBlock(repo, false) : ''].filter(Boolean).join('\n');
|
|
298
267
|
if (o) out(o);
|
|
299
268
|
break;
|
|
300
269
|
}
|
|
301
|
-
case 'recall': {
|
|
302
|
-
// the notes face: supplementary relevant notes for your current changes, from any non-git memory
|
|
303
|
-
// provider (e.g. an Obsidian vault). On-demand. Git history + live debt come from context / debt.
|
|
304
|
-
const repo = repoRoot();
|
|
305
|
-
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
306
|
-
let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
|
|
307
|
-
if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
|
|
308
|
-
if (!changed.length) { out('🐴 sawbuck: nothing changed to look up.'); break; }
|
|
309
|
-
const notes = memRecall(repo, changed);
|
|
310
|
-
if (!notes.length) { out('🐴 sawbuck: no related notes (configure extra memory providers with SAWBUCK_MEMORY, e.g. logbook,obsidian; logbook history is in `sawbuck context`).'); break; }
|
|
311
|
-
out(`🐴 related notes for ${changed.length} changed path${changed.length === 1 ? '' : 's'} (leads, not conclusions):`);
|
|
312
|
-
for (const n of notes) out(` - [${n.source}] ${n.summary}${n.ref ? ` (${n.ref})` : ''}`);
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
315
|
-
case 'memory-conformance': {
|
|
316
|
-
// check that a provider bin speaks the Memory Contract (recall returns Finding[]; debt returns
|
|
317
|
-
// Suppression[] or exits nonzero to signal it is not a git-truth provider). Read-only probe.
|
|
318
|
-
const bin = positional[0];
|
|
319
|
-
if (!bin) die('usage: sawbuck memory-conformance <provider-bin> (a bin speaking the contract verbs recall/debt/record)');
|
|
320
|
-
const r = memConform(bin, process.cwd());
|
|
321
|
-
out(`🐴 memory-conformance: ${bin}`);
|
|
322
|
-
out(` recall: ${r.recall}`);
|
|
323
|
-
out(` debt: ${r.debt}`);
|
|
324
|
-
out(r.pass ? ' ✓ conforms to the Memory Contract.' : ' ✗ does not conform (see above).');
|
|
325
|
-
process.exit(r.pass ? 0 : 1);
|
|
326
|
-
}
|
|
327
270
|
case '__hook': {
|
|
328
271
|
try { execFileSync(process.execPath, [HOOK, ...rest], { stdio: 'inherit' }); } catch (e) { process.exit(e.status || 0); }
|
|
329
272
|
break;
|
|
@@ -333,21 +276,18 @@ switch (cmd) {
|
|
|
333
276
|
const top = gitTop(repoArg());
|
|
334
277
|
if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
|
|
335
278
|
const repo = top || repoArg();
|
|
336
|
-
|
|
337
|
-
if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); if (!dry) verifyAttach(repo); break; }
|
|
338
|
-
wireGit(repo);
|
|
279
|
+
wireClaude(repo, { user });
|
|
339
280
|
if (!dry) verifyAttach(repo);
|
|
340
281
|
break;
|
|
341
282
|
}
|
|
342
283
|
case 'detach': {
|
|
343
284
|
const purge = flags.has('--purge');
|
|
344
|
-
if (flags.has('--all')) { detachAll(purge); break; }
|
|
345
285
|
const repo = repoRoot();
|
|
346
286
|
if (flags.has('--user')) unwireClaude(repo, { user: true });
|
|
347
287
|
else if (flags.has('--claude')) unwireClaude(repo, {});
|
|
348
288
|
else {
|
|
349
|
-
// no flag: reverse whatever this repo's receipt says is attached
|
|
350
|
-
//
|
|
289
|
+
// no flag: reverse whatever this repo's receipt says is attached, older git hooks included,
|
|
290
|
+
// so a bare `detach` is always the inverse of the `attach` you ran.
|
|
351
291
|
const a = readManifest(manifestPath(repo)).attachments || {};
|
|
352
292
|
const hasClaude = !!(a.claude || a['claude-shared']);
|
|
353
293
|
if (hasClaude) unwireClaude(repo, {});
|
|
@@ -357,9 +297,7 @@ switch (cmd) {
|
|
|
357
297
|
if (flags.has('--verify')) verifyClean(repo);
|
|
358
298
|
break;
|
|
359
299
|
}
|
|
360
|
-
case '
|
|
361
|
-
case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
|
|
362
|
-
case 'where': { whereAttached(); break; }
|
|
300
|
+
case 'doctor': { doctorRepo(repoRoot()); break; }
|
|
363
301
|
case 'rules': { out(rulesCli(rest)); break; }
|
|
364
302
|
case 'shape': { out(shapeCli(rest)); break; }
|
|
365
303
|
case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
|
|
@@ -370,22 +308,16 @@ switch (cmd) {
|
|
|
370
308
|
if (!repo) process.stdout.write('🐴 sawbuck · no repo here');
|
|
371
309
|
if (repo) {
|
|
372
310
|
const name = basename(repo);
|
|
373
|
-
|
|
374
|
-
try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
|
|
311
|
+
const { caught, state, steer } = repoState(repo);
|
|
375
312
|
// the last verdict from the activity feed (includes verified passes), so you see it working each turn
|
|
376
313
|
let last = '';
|
|
377
314
|
try { const L = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').trim().split('\n'); const e = JSON.parse(L[L.length - 1]); last = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎' }[e.kind] || ''; } catch {}
|
|
378
|
-
|
|
379
|
-
const active = existsSync(join(repo, '.sawbuck', 'state.json'));
|
|
380
|
-
const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
|
|
381
|
-
const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
|
|
382
|
-
const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
|
|
315
|
+
const label = caught > 0 ? `${caught} caught` : state;
|
|
383
316
|
const seg = label ? `🐴 sawbuck · ${label}` : '';
|
|
384
317
|
const named = label ? `🐴 sawbuck · ${name} · ${label}` : ''; // which repo it detected, in the default line
|
|
385
318
|
const base = named ? (last ? `${named} · ${last}` : named) : '';
|
|
386
319
|
// live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
|
|
387
|
-
|
|
388
|
-
try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) { const p = Math.round(((sj.ema != null ? sj.ema : sj.steeringRate) || 0) * 100); st = `${p >= 50 ? '✊' : '🐎'} steer ${p}%`; } } catch {}
|
|
320
|
+
const st = steer ? `${steer.rate >= 50 ? '✊' : '🐎'} steer ${steer.rate}%` : '';
|
|
389
321
|
// configurable template: SAWBUCK_STATUSLINE picks which segments show and in what order
|
|
390
322
|
// (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
|
|
391
323
|
// so you never get a dangling separator. Unset = the default composition. Mix in other
|
|
@@ -407,7 +339,24 @@ switch (cmd) {
|
|
|
407
339
|
default: die(`unknown command '${cmd}'\n\n${help}`);
|
|
408
340
|
}
|
|
409
341
|
|
|
410
|
-
|
|
342
|
+
function logReport(repo, rest) {
|
|
343
|
+
let lines = [];
|
|
344
|
+
try { lines = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').split('\n').filter(Boolean); } catch {}
|
|
345
|
+
if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); return; }
|
|
346
|
+
const ni = rest.indexOf('-n');
|
|
347
|
+
const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
|
|
348
|
+
const icon = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
|
|
349
|
+
const recent = lines.slice(-n).reverse();
|
|
350
|
+
out(`sawbuck activity (last ${recent.length}):`);
|
|
351
|
+
for (const l of recent) {
|
|
352
|
+
try {
|
|
353
|
+
const e = JSON.parse(l);
|
|
354
|
+
out(` ${(e.at || '').slice(0, 16).replace('T', ' ')} ${icon[e.kind] || '·'} ${String(e.kind).padEnd(11)} ${e.file || ''}${e.reason ? ' ' + e.reason : ''}`);
|
|
355
|
+
} catch {}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- context: the ranked history for the files in hand, on demand ---------------------------
|
|
411
360
|
// the read face: ranked relevant history for what you're working on right now. Silent when there is
|
|
412
361
|
// nothing to say, so it is safe on every UserPromptSubmit (that hook's stdout is injected into context;
|
|
413
362
|
// "nothing notable" every turn would be noise). `speak` forces a reply for the explicit command.
|
|
@@ -456,67 +405,18 @@ function delFile(p) { try { if (existsSync(p)) unlinkSync(p); } catch {} }
|
|
|
456
405
|
function rmIfEmptyDir(d) { try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
|
|
457
406
|
|
|
458
407
|
// --- .git/info/exclude: keep the worktree clean without touching .gitignore --
|
|
459
|
-
//
|
|
460
|
-
// attachments need. Never deletes the file (an empty one is git's own default).
|
|
408
|
+
// Any attachment keeps the block, the last one out takes it. Returns whether the file changed.
|
|
461
409
|
function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
|
|
462
|
-
function syncExclude(repo, manifest) {
|
|
463
|
-
const need = new Set();
|
|
464
|
-
const a = manifest.attachments || {};
|
|
465
|
-
if (Object.keys(a).length) for (const d of RECORD_DIRS) need.add(d); // any attach → the check may write the record dirs
|
|
466
|
-
if (a.claude) need.add('.claude/settings.local.json'); // default claude settings are local; hide them too
|
|
467
|
-
const lines = [...need];
|
|
468
|
-
const f = excludeFile(repo);
|
|
469
|
-
let body = (existsSync(f) ? readFileSync(f, 'utf8') : '').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
|
|
470
|
-
if (lines.length) {
|
|
471
|
-
if (body && !body.endsWith('\n')) body += '\n';
|
|
472
|
-
body += `${S_START}\n${lines.join('\n')}\n${S_END}\n`;
|
|
473
|
-
mkdirSync(dirname(f), { recursive: true });
|
|
474
|
-
writeFileSync(f, body);
|
|
475
|
-
} else if (existsSync(f)) {
|
|
476
|
-
writeFileSync(f, body); // strip our block, leave the rest
|
|
477
|
-
}
|
|
478
|
-
}
|
|
410
|
+
function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
|
|
479
411
|
|
|
480
412
|
// --- git post-commit --------------------------------------------------------
|
|
481
|
-
//
|
|
482
|
-
// falls back to the absolute path of the sawbuck that attached (dev checkouts).
|
|
483
|
-
function hookBlock(repo) {
|
|
484
|
-
const r = JSON.stringify(repo);
|
|
485
|
-
// git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check
|
|
486
|
-
// shells out to git (worktrees, diffs) and would inherit them, operate on the wrong
|
|
487
|
-
// repo, and silently catch nothing. Clear them so the reflex runs against `repo` clean.
|
|
488
|
-
return `${S_START}\nunset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_COMMON_DIR\nif command -v sawbuck >/dev/null 2>&1; then sawbuck __hook --repo ${r}; else ${nodeCmd()} ${JSON.stringify(SELF)} __hook --repo ${r}; fi || true\n${S_END}\n`;
|
|
489
|
-
}
|
|
490
|
-
function wireGit(repo) {
|
|
491
|
-
if (!gitTop(repo)) usage(`${repo} is not a git repository`);
|
|
492
|
-
const pc = gitPath(repo, 'hooks/post-commit');
|
|
493
|
-
const existed = existsSync(pc);
|
|
494
|
-
let body = existed ? readFileSync(pc, 'utf8') : '#!/bin/sh\n';
|
|
495
|
-
if (body.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
|
|
496
|
-
if (!body.startsWith('#!')) body = '#!/bin/sh\n' + body;
|
|
497
|
-
if (!body.endsWith('\n')) body += '\n';
|
|
498
|
-
if (dry) {
|
|
499
|
-
out(dryText([
|
|
500
|
-
`${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
|
|
501
|
-
`exclude ${excludeFile(repo)} (${RECORD_DIRS.join(', ')})`,
|
|
502
|
-
`create ${manifestPath(repo)} (attach receipt)`,
|
|
503
|
-
]));
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
writeFileSync(pc, body + hookBlock(repo));
|
|
507
|
-
chmodSync(pc, 0o755);
|
|
508
|
-
const m = readManifest(manifestPath(repo));
|
|
509
|
-
m.attachments = { ...m.attachments, git: { pc: { created: !existed } } };
|
|
510
|
-
syncExclude(repo, m);
|
|
511
|
-
writeManifest(manifestPath(repo), m);
|
|
512
|
-
regAdd(repo, 'git');
|
|
513
|
-
out(`sawbuck: attached (git post-commit) in ${repo}`);
|
|
514
|
-
}
|
|
413
|
+
// Older versions could attach here. Nothing wires it now; detach still takes it out.
|
|
515
414
|
function unwireGit(repo) {
|
|
516
415
|
const pc = gitPath(repo, 'hooks/post-commit');
|
|
517
416
|
const m = readManifest(manifestPath(repo));
|
|
518
417
|
const hasReceipt = !!m.attachments?.git;
|
|
519
|
-
|
|
418
|
+
// signing with no attach still hides the record, so a repo with no receipt can have a block to take back
|
|
419
|
+
if (!existsSync(pc) && !hasReceipt) { out(syncExclude(repo, m) ? `sawbuck: removed the record lines from ${excludeFile(repo)}` : 'sawbuck: nothing to detach'); return; }
|
|
520
420
|
if (existsSync(pc)) {
|
|
521
421
|
const stripped = readFileSync(pc, 'utf8').replace(BLOCK_RE, '\n').replace(/\n{3,}/g, '\n\n');
|
|
522
422
|
const created = m.attachments?.git?.pc?.created;
|
|
@@ -525,7 +425,6 @@ function unwireGit(repo) {
|
|
|
525
425
|
else writeFileSync(pc, stripped);
|
|
526
426
|
}
|
|
527
427
|
if (hasReceipt) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
|
|
528
|
-
regRemove(repo, 'git');
|
|
529
428
|
out(`sawbuck: detached (git) from ${repo}`);
|
|
530
429
|
}
|
|
531
430
|
function isTrivialHook(text) { return text.replace(/^#!.*\n?/, '').trim() === ''; }
|
|
@@ -547,10 +446,10 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
|
|
|
547
446
|
return `"$(command -v node || echo ${JSON.stringify(execPath).replace(/^"|"$/g, "'")})"`;
|
|
548
447
|
}
|
|
549
448
|
function wireClaude(repo, opts) {
|
|
550
|
-
const { user
|
|
449
|
+
const { user } = opts;
|
|
551
450
|
// user-level hooks already cover every repo; a second set would run each check twice and spend the
|
|
552
451
|
// send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
|
|
553
|
-
if (!user
|
|
452
|
+
if (!user) {
|
|
554
453
|
const uf = claudeFile(repo, { user: true }).f;
|
|
555
454
|
try { if (readFileSync(uf, 'utf8').includes('sawbuck.mjs')) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add`); return; } } catch {}
|
|
556
455
|
}
|
|
@@ -578,7 +477,7 @@ function wireClaude(repo, opts) {
|
|
|
578
477
|
if (dry) {
|
|
579
478
|
out(dryText([
|
|
580
479
|
`${upgrading ? 'update' : fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
|
|
581
|
-
user ? null : `exclude ${excludeFile(repo)} (${
|
|
480
|
+
user ? null : `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
|
|
582
481
|
`create ${user ? userManifestPath() : manifestPath(repo)} (attach receipt)`,
|
|
583
482
|
].filter(Boolean)));
|
|
584
483
|
return;
|
|
@@ -589,8 +488,8 @@ function wireClaude(repo, opts) {
|
|
|
589
488
|
cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
|
|
590
489
|
};
|
|
591
490
|
put('UserPromptSubmit', ctx);
|
|
592
|
-
// right after a compaction the summary has just dropped detail: re-impress the contract frame
|
|
593
|
-
//
|
|
491
|
+
// right after a compaction the summary has just dropped detail: re-impress the contract frame there too
|
|
492
|
+
// (SessionStart with the `compact` source injects stdout into context).
|
|
594
493
|
put('SessionStart', ctx, 'compact');
|
|
595
494
|
put('Stop', chk);
|
|
596
495
|
mkdirSync(dir, { recursive: true });
|
|
@@ -610,19 +509,17 @@ function wireClaude(repo, opts) {
|
|
|
610
509
|
}
|
|
611
510
|
} catch {}
|
|
612
511
|
}
|
|
613
|
-
const mode = user ? 'user' :
|
|
512
|
+
const mode = user ? 'user' : 'claude';
|
|
614
513
|
const mp = user ? userManifestPath() : manifestPath(repo);
|
|
615
514
|
const m = readManifest(mp);
|
|
616
515
|
const prev = (m.attachments || {})[mode]; // an upgrade keeps the receipt of what the first attach created
|
|
617
516
|
m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: prev ? !!prev.fileCreated : !fileExisted, dirCreated: prev ? !!prev.dirCreated : !dirExisted } };
|
|
618
517
|
if (!user) syncExclude(repo, m);
|
|
619
518
|
writeManifest(mp, m);
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
: `sawbuck: wired ${user ? 'USER-LEVEL' : shared ? 'shared project' : 'project'} Claude Code hooks in ${f}`);
|
|
519
|
+
out(upgrading ? `sawbuck: updated the ${user ? 'USER-LEVEL' : 'project'} Claude Code hooks in ${f} to this version`
|
|
520
|
+
: `sawbuck: wired ${user ? 'USER-LEVEL' : 'project'} Claude Code hooks in ${f}`);
|
|
623
521
|
if (!user && !existsSync(join(repo, 'AGENTS.md')) && !existsSync(join(repo, 'CLAUDE.md'))) out(' no written rule here yet, and that is what changes the agent: run sawbuck contract.');
|
|
624
522
|
if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
|
|
625
|
-
else if (!shared) out(' (settings.local.json is gitignored; use --shared to commit the hooks for your team)');
|
|
626
523
|
}
|
|
627
524
|
function unwireClaude(repo, { user }) {
|
|
628
525
|
const mp = user ? userManifestPath() : manifestPath(repo);
|
|
@@ -648,7 +545,6 @@ function unwireClaude(repo, { user }) {
|
|
|
648
545
|
if (rec) { delete m.attachments[mode]; touched = true; }
|
|
649
546
|
}
|
|
650
547
|
if (touched) { if (!user) syncExclude(repo, m); writeManifest(mp, m); }
|
|
651
|
-
for (const mode of modes) regRemove(repo, mode);
|
|
652
548
|
if (!did && !touched) out('sawbuck: no Claude attachment found to detach');
|
|
653
549
|
}
|
|
654
550
|
function removeClaudeEntries(cfg) {
|
|
@@ -677,13 +573,6 @@ function footprint(repo) {
|
|
|
677
573
|
if (existsSync(mp)) { const m = readManifest(mp); items.push({ what: mp, state: `receipt: ${Object.keys(m.attachments || {}).join(', ') || 'empty'}` }); }
|
|
678
574
|
return items;
|
|
679
575
|
}
|
|
680
|
-
function traceRepo(repo) {
|
|
681
|
-
const items = footprint(repo);
|
|
682
|
-
if (!items.length) { out(`sawbuck: no footprint in ${repo}, nothing attached.`); return; }
|
|
683
|
-
out(`sawbuck footprint in ${repo}:`);
|
|
684
|
-
for (const it of items) out(` ${it.state.padEnd(22)} ${it.what}`);
|
|
685
|
-
out('\n detach reverses every line above; `detach --verify` proves it.');
|
|
686
|
-
}
|
|
687
576
|
function verifyClean(repo) {
|
|
688
577
|
// the record dirs may stay (your data, unless --purge); the mechanism must be gone.
|
|
689
578
|
const left = footprint(repo).filter((it) => !/record dir/.test(it.state));
|
|
@@ -728,41 +617,5 @@ function doctorRepo(repo) {
|
|
|
728
617
|
out(pathOk ? '\n sawbuck resolves on PATH, attachments survive a reinstall.' : '\n tip: `npm i -g sawbuck` so hooks resolve by PATH and survive an upgrade.');
|
|
729
618
|
if (bad) process.exitCode = 1;
|
|
730
619
|
}
|
|
731
|
-
function doctorAll() {
|
|
732
|
-
const r = regRead();
|
|
733
|
-
if (!r.repos.length) { out('sawbuck: nothing to check (registry empty).'); return; }
|
|
734
|
-
const repos = [...new Set(r.repos.map((x) => x.repo))];
|
|
735
|
-
for (const repo of repos) doctorRepo(repo);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// --- registry: know and remove every attachment -----------------------------
|
|
739
|
-
function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
|
|
740
|
-
function regRead() { try { const r = JSON.parse(readFileSync(regPath(), 'utf8')); r.repos = (r.repos || []).filter((x) => existsSync(x.repo)); return r; } catch { return { repos: [] }; } } // a repo that is gone is not attached
|
|
741
|
-
function regWrite(r) {
|
|
742
|
-
const f = regPath();
|
|
743
|
-
if (!r.repos.length) { delFile(f); rmIfEmptyDir(dirname(f)); return; }
|
|
744
|
-
mkdirSync(dirname(f), { recursive: true });
|
|
745
|
-
writeFileSync(f, JSON.stringify(r, null, 2) + '\n');
|
|
746
|
-
}
|
|
747
|
-
function regAdd(repo, mode) { const r = regRead(); r.repos = r.repos.filter((x) => !(x.repo === repo && x.mode === mode)); r.repos.push({ repo, mode, at: new Date().toISOString() }); regWrite(r); }
|
|
748
|
-
function regRemove(repo, mode) { const r = regRead(); r.repos = r.repos.filter((x) => !(x.repo === repo && x.mode === mode)); regWrite(r); }
|
|
749
|
-
function whereAttached() {
|
|
750
|
-
const r = regRead();
|
|
751
|
-
if (!r.repos.length) { out('sawbuck: not attached anywhere (registry empty).'); return; }
|
|
752
|
-
out('sawbuck is attached to:');
|
|
753
|
-
for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.repo}`);
|
|
754
|
-
}
|
|
755
|
-
function detachAll(purge) {
|
|
756
|
-
const r = regRead();
|
|
757
|
-
if (!r.repos.length) { out('sawbuck: nothing to detach (registry empty).'); return; }
|
|
758
|
-
for (const x of [...r.repos]) {
|
|
759
|
-
if (x.mode === 'git') unwireGit(x.repo);
|
|
760
|
-
else if (x.mode === 'user') unwireClaude(x.repo, { user: true });
|
|
761
|
-
else unwireClaude(x.repo, {});
|
|
762
|
-
if (purge) purgeRecord(x.repo);
|
|
763
|
-
}
|
|
764
|
-
out('sawbuck: detached everywhere.');
|
|
765
|
-
}
|
|
766
|
-
|
|
767
620
|
// --- shared helpers ---------------------------------------------------------
|
|
768
621
|
function dryText(plan) { return ['sawbuck --dry-run: would leave this footprint (writing nothing):', ...plan.map((p) => ' ' + p), ' all of it reversible with `detach`.'].join('\n'); }
|