sawbuck 0.1.44 → 0.1.46
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 +3 -9
- package/rank.mjs +5 -5
- package/reflect.mjs +2 -2
- package/repo-state.mjs +1 -1
- 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/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-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();
|
package/sawbuck.mjs
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
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
10
|
import { erasedBlock, erasedCommits } from './rewrites.mjs';
|
|
@@ -23,7 +17,6 @@ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSy
|
|
|
23
17
|
import { basename, join, dirname, resolve } from 'node:path';
|
|
24
18
|
import { fileURLToPath } from 'node:url';
|
|
25
19
|
import { homedir } from 'node:os';
|
|
26
|
-
import { recall as memRecall, conform as memConform } from './memory.mjs';
|
|
27
20
|
import { PW } from './bins.mjs';
|
|
28
21
|
import { frameBlock, mandatesInForce, protectedDirty, blobAt, lockState } from './contract.mjs';
|
|
29
22
|
|
|
@@ -54,7 +47,7 @@ const argv = process.argv.slice(2);
|
|
|
54
47
|
const cmd = argv[0];
|
|
55
48
|
const rest = argv.slice(1);
|
|
56
49
|
const flags = new Set(rest.filter((a) => a.startsWith('--')));
|
|
57
|
-
// `-n N` (
|
|
50
|
+
// `-n N` (status --log) is a flag with a value, not a repo path.
|
|
58
51
|
const positional = rest.filter((a, i) => !a.startsWith('--') && a !== '-n' && rest[i - 1] !== '-n');
|
|
59
52
|
const repoArg = () => resolve(positional[0] || '.');
|
|
60
53
|
const dry = flags.has('--dry-run');
|
|
@@ -62,7 +55,7 @@ const dry = flags.has('--dry-run');
|
|
|
62
55
|
let version = '0.0.0';
|
|
63
56
|
try { version = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).version; } catch {}
|
|
64
57
|
|
|
65
|
-
const help = `sawbuck ${version}:
|
|
58
|
+
const help = `sawbuck ${version}: write down the rules your agent follows, and prove they held
|
|
66
59
|
|
|
67
60
|
Usage:
|
|
68
61
|
sawbuck check [repo] [--base R --head R] [--repeat N] [--allow "<reason>"] [--json]
|
|
@@ -71,29 +64,22 @@ Usage:
|
|
|
71
64
|
fastest way to see it work. Every promptwheel run flag passes through.
|
|
72
65
|
Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
|
|
73
66
|
sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
|
|
74
|
-
Customize the
|
|
75
|
-
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
|
|
76
69
|
(--no-agent-block to skip). Wizard in a terminal, non-interactive in a pipe/CI (or --yes).
|
|
77
|
-
sawbuck attach
|
|
78
|
-
|
|
79
|
-
--user for every repo
|
|
80
|
-
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]
|
|
81
72
|
Remove it and restore the repo (no flag: whatever is attached here); --verify
|
|
82
73
|
proves it came back clean; --purge also deletes the record (.sawbuck/)
|
|
83
74
|
sawbuck status [repo] The inventory: caught over time + debt accruing now + whether it is armed. --json
|
|
84
|
-
|
|
75
|
+
--log [-n N] for the recent checks, newest first
|
|
85
76
|
sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
|
|
86
77
|
The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
|
|
87
78
|
shows only what a branch adds; --fail-on exits nonzero (a CI gate)
|
|
88
79
|
sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
|
|
89
|
-
sawbuck
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
sawbuck trace [repo] Show every file Sawbuck has placed in this repo (the footprint)
|
|
94
|
-
sawbuck doctor [repo] Check the attachment still resolves to a live sawbuck; --all for every repo
|
|
95
|
-
sawbuck where List every repo Sawbuck is attached to
|
|
96
|
-
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
|
|
97
83
|
sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
|
|
98
84
|
sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
|
|
99
85
|
sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
|
|
@@ -119,9 +105,9 @@ const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
|
|
|
119
105
|
|
|
120
106
|
if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
|
|
121
107
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
122
|
-
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
|
|
123
109
|
|
|
124
|
-
sawbuck contract
|
|
110
|
+
sawbuck contract give this repo its rules: protected paths, your tests
|
|
125
111
|
sawbuck check verify your latest change right now
|
|
126
112
|
sawbuck status what it has caught here
|
|
127
113
|
|
|
@@ -132,8 +118,8 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
132
118
|
switch (cmd) {
|
|
133
119
|
case 'contract': {
|
|
134
120
|
const top = gitTop(repoArg());
|
|
135
|
-
if (!top) usage(`${repoArg()} is not a git repository (
|
|
136
|
-
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' }); }
|
|
137
123
|
catch (e) { process.exit(e.status || 1); }
|
|
138
124
|
break;
|
|
139
125
|
}
|
|
@@ -144,7 +130,8 @@ switch (cmd) {
|
|
|
144
130
|
}
|
|
145
131
|
case 'status': {
|
|
146
132
|
// the inventory: one view unioning what the check has caught over time, what debt is accruing
|
|
147
|
-
// 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; }
|
|
148
135
|
try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoArg(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
|
|
149
136
|
catch (e) { process.exit(e.status || 1); }
|
|
150
137
|
break;
|
|
@@ -187,7 +174,7 @@ switch (cmd) {
|
|
|
187
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).`);
|
|
188
175
|
else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
|
|
189
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.');
|
|
190
|
-
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.');
|
|
191
178
|
else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
|
|
192
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.');
|
|
193
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.');
|
|
@@ -195,25 +182,6 @@ switch (cmd) {
|
|
|
195
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.`);
|
|
196
183
|
process.exit(code);
|
|
197
184
|
}
|
|
198
|
-
case 'log': {
|
|
199
|
-
// the event feed: a running log of what the check did, so you can watch it work.
|
|
200
|
-
const repo = repoArg();
|
|
201
|
-
let lines = [];
|
|
202
|
-
try { lines = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').split('\n').filter(Boolean); } catch {}
|
|
203
|
-
if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
|
|
204
|
-
const ni = rest.indexOf('-n');
|
|
205
|
-
const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
|
|
206
|
-
const icon = { verified: '✓', gamed: '🚩', regression: '✗', inconclusive: '≈', allowed: '✎', mandate: '⛔' };
|
|
207
|
-
const recent = lines.slice(-n).reverse();
|
|
208
|
-
out(`sawbuck activity (last ${recent.length}):`);
|
|
209
|
-
for (const l of recent) {
|
|
210
|
-
try {
|
|
211
|
-
const e = JSON.parse(l);
|
|
212
|
-
out(` ${(e.at || '').slice(0, 16).replace('T', ' ')} ${icon[e.kind] || '·'} ${String(e.kind).padEnd(11)} ${e.file || ''}${e.reason ? ' ' + e.reason : ''}`);
|
|
213
|
-
} catch {}
|
|
214
|
-
}
|
|
215
|
-
break;
|
|
216
|
-
}
|
|
217
185
|
case 'rank': {
|
|
218
186
|
try { execFileSync(process.execPath, [join(HERE, 'rank.mjs'), ...rest], { stdio: 'inherit' }); }
|
|
219
187
|
catch (e) { process.exit(e.status || 1); }
|
|
@@ -239,7 +207,7 @@ switch (cmd) {
|
|
|
239
207
|
// the ONE per-prompt hook. The agent gets the contract frame once a session and again after a compaction: one
|
|
240
208
|
// written statement held in every bench, so saying it on each prompt only spends context. The relevant history
|
|
241
209
|
// is on demand (`sawbuck context`), and per turn only with {"turn":{"context":true}} in sawbuck.config.json.
|
|
242
|
-
// Wired by `attach
|
|
210
|
+
// Wired by `attach` on UserPromptSubmit and after a compaction.
|
|
243
211
|
// the repository the way the Stop hook sees it: the root of the cwd under user-level wiring (a
|
|
244
212
|
// subdirectory belongs to its repo), the pinned path under project wiring; '' outside a repo
|
|
245
213
|
const top = gitTop(repoArg());
|
|
@@ -299,32 +267,6 @@ switch (cmd) {
|
|
|
299
267
|
if (o) out(o);
|
|
300
268
|
break;
|
|
301
269
|
}
|
|
302
|
-
case 'recall': {
|
|
303
|
-
// the notes face: supplementary relevant notes for your current changes, from any non-git memory
|
|
304
|
-
// provider (e.g. an Obsidian vault). On-demand. Git history + live debt come from context / debt.
|
|
305
|
-
const repo = repoRoot();
|
|
306
|
-
const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
307
|
-
let changed = g('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
|
|
308
|
-
if (!changed.length) changed = g('diff', '--name-only', 'HEAD~1', 'HEAD').split('\n').filter(Boolean);
|
|
309
|
-
if (!changed.length) { out('🐴 sawbuck: nothing changed to look up.'); break; }
|
|
310
|
-
const notes = memRecall(repo, changed);
|
|
311
|
-
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; }
|
|
312
|
-
out(`🐴 related notes for ${changed.length} changed path${changed.length === 1 ? '' : 's'} (leads, not conclusions):`);
|
|
313
|
-
for (const n of notes) out(` - [${n.source}] ${n.summary}${n.ref ? ` (${n.ref})` : ''}`);
|
|
314
|
-
break;
|
|
315
|
-
}
|
|
316
|
-
case 'memory-conformance': {
|
|
317
|
-
// check that a provider bin speaks the Memory Contract (recall returns Finding[]; debt returns
|
|
318
|
-
// Suppression[] or exits nonzero to signal it is not a git-truth provider). Read-only probe.
|
|
319
|
-
const bin = positional[0];
|
|
320
|
-
if (!bin) die('usage: sawbuck memory-conformance <provider-bin> (a bin speaking the contract verbs recall/debt/record)');
|
|
321
|
-
const r = memConform(bin, process.cwd());
|
|
322
|
-
out(`🐴 memory-conformance: ${bin}`);
|
|
323
|
-
out(` recall: ${r.recall}`);
|
|
324
|
-
out(` debt: ${r.debt}`);
|
|
325
|
-
out(r.pass ? ' ✓ conforms to the Memory Contract.' : ' ✗ does not conform (see above).');
|
|
326
|
-
process.exit(r.pass ? 0 : 1);
|
|
327
|
-
}
|
|
328
270
|
case '__hook': {
|
|
329
271
|
try { execFileSync(process.execPath, [HOOK, ...rest], { stdio: 'inherit' }); } catch (e) { process.exit(e.status || 0); }
|
|
330
272
|
break;
|
|
@@ -334,21 +276,18 @@ switch (cmd) {
|
|
|
334
276
|
const top = gitTop(repoArg());
|
|
335
277
|
if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
|
|
336
278
|
const repo = top || repoArg();
|
|
337
|
-
|
|
338
|
-
if (flags.has('--claude')) { wireClaude(repo, { shared: flags.has('--shared') }); if (!dry) verifyAttach(repo); break; }
|
|
339
|
-
wireGit(repo);
|
|
279
|
+
wireClaude(repo, { user });
|
|
340
280
|
if (!dry) verifyAttach(repo);
|
|
341
281
|
break;
|
|
342
282
|
}
|
|
343
283
|
case 'detach': {
|
|
344
284
|
const purge = flags.has('--purge');
|
|
345
|
-
if (flags.has('--all')) { detachAll(purge); break; }
|
|
346
285
|
const repo = repoRoot();
|
|
347
286
|
if (flags.has('--user')) unwireClaude(repo, { user: true });
|
|
348
287
|
else if (flags.has('--claude')) unwireClaude(repo, {});
|
|
349
288
|
else {
|
|
350
|
-
// no flag: reverse whatever this repo's receipt says is attached
|
|
351
|
-
//
|
|
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.
|
|
352
291
|
const a = readManifest(manifestPath(repo)).attachments || {};
|
|
353
292
|
const hasClaude = !!(a.claude || a['claude-shared']);
|
|
354
293
|
if (hasClaude) unwireClaude(repo, {});
|
|
@@ -358,9 +297,7 @@ switch (cmd) {
|
|
|
358
297
|
if (flags.has('--verify')) verifyClean(repo);
|
|
359
298
|
break;
|
|
360
299
|
}
|
|
361
|
-
case '
|
|
362
|
-
case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
|
|
363
|
-
case 'where': { whereAttached(); break; }
|
|
300
|
+
case 'doctor': { doctorRepo(repoRoot()); break; }
|
|
364
301
|
case 'rules': { out(rulesCli(rest)); break; }
|
|
365
302
|
case 'shape': { out(shapeCli(rest)); break; }
|
|
366
303
|
case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
|
|
@@ -402,6 +339,23 @@ switch (cmd) {
|
|
|
402
339
|
default: die(`unknown command '${cmd}'\n\n${help}`);
|
|
403
340
|
}
|
|
404
341
|
|
|
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
|
+
|
|
405
359
|
// --- context: the ranked history for the files in hand, on demand ---------------------------
|
|
406
360
|
// the read face: ranked relevant history for what you're working on right now. Silent when there is
|
|
407
361
|
// nothing to say, so it is safe on every UserPromptSubmit (that hook's stdout is injected into context;
|
|
@@ -456,40 +410,7 @@ function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
|
|
|
456
410
|
function syncExclude(repo, manifest) { return Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo) : clearExcluded(repo); }
|
|
457
411
|
|
|
458
412
|
// --- git post-commit --------------------------------------------------------
|
|
459
|
-
//
|
|
460
|
-
// falls back to the absolute path of the sawbuck that attached (dev checkouts).
|
|
461
|
-
function hookBlock(repo) {
|
|
462
|
-
const r = JSON.stringify(repo);
|
|
463
|
-
// git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check
|
|
464
|
-
// shells out to git (worktrees, diffs) and would inherit them, operate on the wrong
|
|
465
|
-
// repo, and silently catch nothing. Clear them so the reflex runs against `repo` clean.
|
|
466
|
-
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`;
|
|
467
|
-
}
|
|
468
|
-
function wireGit(repo) {
|
|
469
|
-
if (!gitTop(repo)) usage(`${repo} is not a git repository`);
|
|
470
|
-
const pc = gitPath(repo, 'hooks/post-commit');
|
|
471
|
-
const existed = existsSync(pc);
|
|
472
|
-
let body = existed ? readFileSync(pc, 'utf8') : '#!/bin/sh\n';
|
|
473
|
-
if (body.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
|
|
474
|
-
if (!body.startsWith('#!')) body = '#!/bin/sh\n' + body;
|
|
475
|
-
if (!body.endsWith('\n')) body += '\n';
|
|
476
|
-
if (dry) {
|
|
477
|
-
out(dryText([
|
|
478
|
-
`${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
|
|
479
|
-
`exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})`,
|
|
480
|
-
`create ${manifestPath(repo)} (attach receipt)`,
|
|
481
|
-
]));
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
writeFileSync(pc, body + hookBlock(repo));
|
|
485
|
-
chmodSync(pc, 0o755);
|
|
486
|
-
const m = readManifest(manifestPath(repo));
|
|
487
|
-
m.attachments = { ...m.attachments, git: { pc: { created: !existed } } };
|
|
488
|
-
syncExclude(repo, m);
|
|
489
|
-
writeManifest(manifestPath(repo), m);
|
|
490
|
-
regAdd(repo, 'git');
|
|
491
|
-
out(`sawbuck: attached (git post-commit) in ${repo}`);
|
|
492
|
-
}
|
|
413
|
+
// Older versions could attach here. Nothing wires it now; detach still takes it out.
|
|
493
414
|
function unwireGit(repo) {
|
|
494
415
|
const pc = gitPath(repo, 'hooks/post-commit');
|
|
495
416
|
const m = readManifest(manifestPath(repo));
|
|
@@ -504,7 +425,6 @@ function unwireGit(repo) {
|
|
|
504
425
|
else writeFileSync(pc, stripped);
|
|
505
426
|
}
|
|
506
427
|
if (hasReceipt) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
|
|
507
|
-
regRemove(repo, 'git');
|
|
508
428
|
out(`sawbuck: detached (git) from ${repo}`);
|
|
509
429
|
}
|
|
510
430
|
function isTrivialHook(text) { return text.replace(/^#!.*\n?/, '').trim() === ''; }
|
|
@@ -526,10 +446,10 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
|
|
|
526
446
|
return `"$(command -v node || echo ${JSON.stringify(execPath).replace(/^"|"$/g, "'")})"`;
|
|
527
447
|
}
|
|
528
448
|
function wireClaude(repo, opts) {
|
|
529
|
-
const { user
|
|
449
|
+
const { user } = opts;
|
|
530
450
|
// user-level hooks already cover every repo; a second set would run each check twice and spend the
|
|
531
451
|
// send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
|
|
532
|
-
if (!user
|
|
452
|
+
if (!user) {
|
|
533
453
|
const uf = claudeFile(repo, { user: true }).f;
|
|
534
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 {}
|
|
535
455
|
}
|
|
@@ -589,19 +509,17 @@ function wireClaude(repo, opts) {
|
|
|
589
509
|
}
|
|
590
510
|
} catch {}
|
|
591
511
|
}
|
|
592
|
-
const mode = user ? 'user' :
|
|
512
|
+
const mode = user ? 'user' : 'claude';
|
|
593
513
|
const mp = user ? userManifestPath() : manifestPath(repo);
|
|
594
514
|
const m = readManifest(mp);
|
|
595
515
|
const prev = (m.attachments || {})[mode]; // an upgrade keeps the receipt of what the first attach created
|
|
596
516
|
m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: prev ? !!prev.fileCreated : !fileExisted, dirCreated: prev ? !!prev.dirCreated : !dirExisted } };
|
|
597
517
|
if (!user) syncExclude(repo, m);
|
|
598
518
|
writeManifest(mp, m);
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
: `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}`);
|
|
602
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.');
|
|
603
522
|
if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
|
|
604
|
-
else if (!shared) out(' (settings.local.json is gitignored; use --shared to commit the hooks for your team)');
|
|
605
523
|
}
|
|
606
524
|
function unwireClaude(repo, { user }) {
|
|
607
525
|
const mp = user ? userManifestPath() : manifestPath(repo);
|
|
@@ -627,7 +545,6 @@ function unwireClaude(repo, { user }) {
|
|
|
627
545
|
if (rec) { delete m.attachments[mode]; touched = true; }
|
|
628
546
|
}
|
|
629
547
|
if (touched) { if (!user) syncExclude(repo, m); writeManifest(mp, m); }
|
|
630
|
-
for (const mode of modes) regRemove(repo, mode);
|
|
631
548
|
if (!did && !touched) out('sawbuck: no Claude attachment found to detach');
|
|
632
549
|
}
|
|
633
550
|
function removeClaudeEntries(cfg) {
|
|
@@ -656,13 +573,6 @@ function footprint(repo) {
|
|
|
656
573
|
if (existsSync(mp)) { const m = readManifest(mp); items.push({ what: mp, state: `receipt: ${Object.keys(m.attachments || {}).join(', ') || 'empty'}` }); }
|
|
657
574
|
return items;
|
|
658
575
|
}
|
|
659
|
-
function traceRepo(repo) {
|
|
660
|
-
const items = footprint(repo);
|
|
661
|
-
if (!items.length) { out(`sawbuck: no footprint in ${repo}, nothing attached.`); return; }
|
|
662
|
-
out(`sawbuck footprint in ${repo}:`);
|
|
663
|
-
for (const it of items) out(` ${it.state.padEnd(22)} ${it.what}`);
|
|
664
|
-
out('\n detach reverses every line above; `detach --verify` proves it.');
|
|
665
|
-
}
|
|
666
576
|
function verifyClean(repo) {
|
|
667
577
|
// the record dirs may stay (your data, unless --purge); the mechanism must be gone.
|
|
668
578
|
const left = footprint(repo).filter((it) => !/record dir/.test(it.state));
|
|
@@ -707,41 +617,5 @@ function doctorRepo(repo) {
|
|
|
707
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.');
|
|
708
618
|
if (bad) process.exitCode = 1;
|
|
709
619
|
}
|
|
710
|
-
function doctorAll() {
|
|
711
|
-
const r = regRead();
|
|
712
|
-
if (!r.repos.length) { out('sawbuck: nothing to check (registry empty).'); return; }
|
|
713
|
-
const repos = [...new Set(r.repos.map((x) => x.repo))];
|
|
714
|
-
for (const repo of repos) doctorRepo(repo);
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// --- registry: know and remove every attachment -----------------------------
|
|
718
|
-
function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
|
|
719
|
-
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
|
|
720
|
-
function regWrite(r) {
|
|
721
|
-
const f = regPath();
|
|
722
|
-
if (!r.repos.length) { delFile(f); rmIfEmptyDir(dirname(f)); return; }
|
|
723
|
-
mkdirSync(dirname(f), { recursive: true });
|
|
724
|
-
writeFileSync(f, JSON.stringify(r, null, 2) + '\n');
|
|
725
|
-
}
|
|
726
|
-
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); }
|
|
727
|
-
function regRemove(repo, mode) { const r = regRead(); r.repos = r.repos.filter((x) => !(x.repo === repo && x.mode === mode)); regWrite(r); }
|
|
728
|
-
function whereAttached() {
|
|
729
|
-
const r = regRead();
|
|
730
|
-
if (!r.repos.length) { out('sawbuck: not attached anywhere (registry empty).'); return; }
|
|
731
|
-
out('sawbuck is attached to:');
|
|
732
|
-
for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.repo}`);
|
|
733
|
-
}
|
|
734
|
-
function detachAll(purge) {
|
|
735
|
-
const r = regRead();
|
|
736
|
-
if (!r.repos.length) { out('sawbuck: nothing to detach (registry empty).'); return; }
|
|
737
|
-
for (const x of [...r.repos]) {
|
|
738
|
-
if (x.mode === 'git') unwireGit(x.repo);
|
|
739
|
-
else if (x.mode === 'user') unwireClaude(x.repo, { user: true });
|
|
740
|
-
else unwireClaude(x.repo, {});
|
|
741
|
-
if (purge) purgeRecord(x.repo);
|
|
742
|
-
}
|
|
743
|
-
out('sawbuck: detached everywhere.');
|
|
744
|
-
}
|
|
745
|
-
|
|
746
620
|
// --- shared helpers ---------------------------------------------------------
|
|
747
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'); }
|
package/shape.mjs
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
// Your steering shape: what you push on, as
|
|
2
|
-
// totals are kept.
|
|
3
|
-
// never shown. It says what you pushed on, not how good you are. Each run is saved with its date
|
|
1
|
+
// Your steering shape: what you push on, as 11 spokes in 4 groups. The model labels a sample of your messages; only the
|
|
2
|
+
// totals are kept. Three models agreed on single messages about 70% of the time and on the shape at 0.87 or better, so a
|
|
3
|
+
// single label is never shown. It says what you pushed on, not how good you are. Each run is saved with its date.
|
|
4
4
|
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmSync, copyFileSync } from 'node:fs';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { join, dirname } from 'node:path';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { prepareRules } from './rules.mjs';
|
|
9
|
-
import { exposure, writtenRules } from './exposure.mjs';
|
|
10
9
|
|
|
11
|
-
export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['
|
|
10
|
+
export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
|
|
12
11
|
const SPOKES = Object.values(GROUPS).flat();
|
|
13
12
|
const MIN_SPOKES = 200; // below this, spokes are noise: show the 4 groups
|
|
14
13
|
const SAMPLE = 600;
|
|
15
|
-
const paths = (home) => ({ dir: join(home, '.sawbuck'), rules: join(home, '.sawbuck', 'rule-input.txt'), input: join(home, '.sawbuck', 'shape-input.txt'), hist: join(home, '.sawbuck', 'shape-history.jsonl'),
|
|
14
|
+
const paths = (home) => ({ dir: join(home, '.sawbuck'), rules: join(home, '.sawbuck', 'rule-input.txt'), input: join(home, '.sawbuck', 'shape-input.txt'), hist: join(home, '.sawbuck', 'shape-history.jsonl'), svg: join(home, '.sawbuck', 'shape.svg') });
|
|
16
15
|
const title = (s) => s[0].toUpperCase() + s.slice(1);
|
|
17
16
|
|
|
18
17
|
export function prepareShape({ home = homedir() } = {}) {
|
|
@@ -22,14 +21,14 @@ export function prepareShape({ home = homedir() } = {}) {
|
|
|
22
21
|
for (let i = lines.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [lines[i], lines[j]] = [lines[j], lines[i]]; }
|
|
23
22
|
const pick = lines.slice(0, SAMPLE);
|
|
24
23
|
writeFileSync(p.input, pick.map((l, i) => `${i + 1}\t${l}`).join('\n') + '\n', { mode: 0o600 });
|
|
25
|
-
writeFileSync(p.expo, JSON.stringify({ ...exposure({ root: join(home, '.claude', 'projects') }), written: writtenRules({ home }) }) + '\n');
|
|
26
24
|
return { input: p.input, lines: pick.length, secrets: r.secrets };
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
export function tally(labels) {
|
|
30
28
|
const counts = Object.fromEntries(SPOKES.map((s) => [s, 0]));
|
|
31
29
|
let none = 0;
|
|
32
|
-
|
|
30
|
+
// two models split scope and direction at random (shape r 0.55 with both, 0.97 merged), so they count as one
|
|
31
|
+
for (const raw of Object.values(labels)) { const v = raw === 'direction' ? 'scope' : raw; if (v === 'none') none++; else if (v in counts) counts[v]++; }
|
|
33
32
|
const total = SPOKES.reduce((a, s) => a + counts[s], 0);
|
|
34
33
|
const groups = Object.fromEntries(Object.entries(GROUPS).map(([g, ss]) => [g, total ? Math.round(100 * ss.reduce((a, s) => a + counts[s], 0) / total) : 0]));
|
|
35
34
|
const top = Math.max(1, ...SPOKES.map((s) => counts[s]));
|
|
@@ -40,41 +39,13 @@ export function tally(labels) {
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
// What was not said: each spoke we can measure from what the agent did, read against how often you push on it.
|
|
43
|
-
|
|
44
|
-
const HIGH_PUSH = 8, HIGH_DOING = 10, HIGH_FAULT = 5; // a fault counts sooner than plain activity
|
|
45
|
-
const SEEN = {
|
|
46
|
-
proof: { fault: true, of: 'code', n: 'unverified', says: 'changed code and ran no check within 3 turns', rule: 'Run the tests before saying a change works.' },
|
|
47
|
-
safety: { fault: true, of: 'working', n: 'risky', says: 'touched something risky', rule: 'Ask before force pushing, deleting outside temp folders, or touching secrets, workflows or deploy files.' },
|
|
48
|
-
process: { of: 'working', n: 'shipped', says: 'committed, pushed or published', rule: 'Do not commit or push unless I ask.' },
|
|
49
|
-
voice: { of: 'working', n: 'copy', says: 'wrote docs or copy', rule: 'Write docs in plain, short sentences.' },
|
|
50
|
-
visual: { of: 'working', n: 'ui', says: 'changed the interface', rule: 'Show me interface changes before calling them done.' },
|
|
51
|
-
simplicity: { fault: true, of: 'working', n: 'big', says: 'added 200 lines or more', rule: 'Keep changes small. Ask before adding more than 200 lines.' },
|
|
52
|
-
naming: { of: 'working', n: 'renames', says: 'renamed something', rule: 'Ask before renaming anything public.' },
|
|
53
|
-
autonomy: { of: 'working', n: 'asked', says: 'stopped to ask you', rule: 'Keep going on reversible work without asking.' },
|
|
54
|
-
};
|
|
55
|
-
export function patterns(t, c) {
|
|
56
|
-
if (!c || !c.working) return [];
|
|
57
|
-
return Object.entries(SEEN).map(([s, m]) => {
|
|
58
|
-
const pct = Math.round(100 * (c[m.n] || 0) / Math.max(1, c[m.of]));
|
|
59
|
-
const push = t.shares[s], often = pct >= (m.fault ? HIGH_FAULT : HIGH_DOING), pushed = push >= HIGH_PUSH;
|
|
60
|
-
// a fault you keep raising is a fight; activity you raise is just attention, since committing is not a failure
|
|
61
|
-
const has = !!(c.written && c.written[s]);
|
|
62
|
-
const enough = c[m.of] >= MIN_TURNS;
|
|
63
|
-
const kind = !enough ? 'too few' : often && pushed ? (m.fault ? 'fighting it' : 'watched') : often ? (has ? 'covered' : 'blind spot') : pushed ? 'handled' : 'quiet';
|
|
64
|
-
return { spoke: s, kind, pct, count: c[m.n] || 0, of: c[m.of], push, says: m.says, rule: m.rule, has };
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function shapeSvg(t, found = []) {
|
|
42
|
+
export function shapeSvg(t) {
|
|
69
43
|
const names = t.spokes ? SPOKES : Object.keys(GROUPS);
|
|
70
44
|
const vals = t.spokes ? names.map((s) => t.ratings[s] / 99) : names.map((g) => 0.25 + 0.75 * t.groups[g] / Math.max(1, ...Object.values(t.groups)));
|
|
71
45
|
const pt = (i, v) => { const a = 2 * Math.PI * i / names.length; return [(500 + 330 * v * Math.sin(a)).toFixed(1), (520 - 330 * v * Math.cos(a)).toFixed(1)]; };
|
|
72
|
-
const doing = Object.fromEntries(found.map((f) => [f.spoke, f.pct]));
|
|
73
|
-
const topDoing = Math.max(1, ...Object.values(doing));
|
|
74
|
-
const seenPts = t.spokes && found.length ? names.flatMap((n, i) => (n in doing ? [pt(i, 0.4 + 0.59 * Math.sqrt(doing[n] / topDoing))] : [])) : [];
|
|
75
46
|
const ring = (v) => names.map((_, i) => pt(i, v).join(',')).join(' ');
|
|
76
47
|
const labels = names.map((n, i) => { const [x, y] = pt(i, 1.14); return `<text x="${x}" y="${y}" text-anchor="middle" fill="#e8e6e1" font-size="22" font-weight="700">${title(n)}</text><text x="${x}" y="${+y + 28}" text-anchor="middle" fill="#c9a24a" font-size="24" font-weight="700">${t.spokes ? t.ratings[n] : t.groups[n] + '%'}</text>`; }).join('');
|
|
77
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" font-family="system-ui,sans-serif"><rect width="1000" height="1000" fill="#0b0e13"/><text x="50" y="70" fill="#e8e6e1" font-size="40" font-weight="700">My steering shape</text>${[0.25, 0.5, 0.75, 1].map((v) => `<polygon points="${ring(v)}" fill="none" stroke="#2a313b"/>`).join('')}<polygon points="${names.map((_, i) => pt(i, vals[i]).join(',')).join(' ')}" fill="#c9a24a" fill-opacity="0.35" stroke="#c9a24a" stroke-width="4"/>${
|
|
48
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" font-family="system-ui,sans-serif"><rect width="1000" height="1000" fill="#0b0e13"/><text x="50" y="70" fill="#e8e6e1" font-size="40" font-weight="700">My steering shape</text>${[0.25, 0.5, 0.75, 1].map((v) => `<polygon points="${ring(v)}" fill="none" stroke="#2a313b"/>`).join('')}<polygon points="${names.map((_, i) => pt(i, vals[i]).join(',')).join(' ')}" fill="#c9a24a" fill-opacity="0.35" stroke="#c9a24a" stroke-width="4"/>${labels}<text x="50" y="960" fill="#8d94a0" font-size="19">Gold: what I push on, from ${t.n} messages. Relative to my own top. Not a score. sawbuck.ai</text></svg>\n`;
|
|
78
49
|
}
|
|
79
50
|
|
|
80
51
|
// A PNG of the card, with what is already on the machine: sawbuck ships no image library. A snap browser sees neither
|
|
@@ -109,25 +80,15 @@ export function shapeCli(args, { home = homedir() } = {}) {
|
|
|
109
80
|
mkdirSync(p.dir, { recursive: true });
|
|
110
81
|
let prev = null;
|
|
111
82
|
if (existsSync(p.hist)) { const l = readFileSync(p.hist, 'utf8').trim().split('\n').filter(Boolean); if (l.length) prev = JSON.parse(l[l.length - 1]); }
|
|
112
|
-
|
|
113
|
-
const found = patterns(t, c);
|
|
114
|
-
writeFileSync(p.svg, shapeSvg(t, found));
|
|
83
|
+
writeFileSync(p.svg, shapeSvg(t));
|
|
115
84
|
const out = [`🐴 your steering shape, from ${t.n} messages that pushed on something (${t.none} did not). What you push on, not a score.`, ''];
|
|
116
85
|
for (const [g, ss] of Object.entries(GROUPS)) {
|
|
117
86
|
const was = prev && prev.groups[g] != null ? ` (was ${prev.groups[g]}%)` : '';
|
|
118
87
|
out.push(` ${g.padEnd(8)} ${String(t.groups[g]).padStart(3)}%${was}`);
|
|
119
88
|
if (t.spokes) out.push(` ${ss.map((s) => `${title(s)} ${t.ratings[s]}`).join(' ')}`);
|
|
120
89
|
}
|
|
121
|
-
if (!t.spokes) out.push('', ` Too few messages for the
|
|
122
|
-
appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares
|
|
123
|
-
const fight = found.filter((f) => f.kind === 'fighting it'), blind = found.filter((f) => f.kind === 'blind spot');
|
|
124
|
-
if (fight.length || blind.length) out.push('', ' Patterns to work on:');
|
|
125
|
-
for (const f of fight) out.push(` Fighting it: ${title(f.spoke)}. You push on it (${f.push}%), and the agent still ${f.says} in ${f.pct}% of turns (${f.count} of ${f.of}).`, f.has ? ' You seem to have a written rule on this already, and it is not holding. Make it specific, or protect it in the contract.' : ` Try: ${f.rule}`);
|
|
126
|
-
for (const f of blind) out.push(` Not said: ${title(f.spoke)}. The agent ${f.says} in ${f.pct}% of turns (${f.count} of ${f.of}), and only ${f.push}% of your pushes touch it. Is that what you want?`, ` Try: ${f.rule}`);
|
|
127
|
-
const covered = found.filter((f) => f.kind === 'covered');
|
|
128
|
-
if (covered.length) out.push('', ` Written down already, so left out: ${covered.map((f) => title(f.spoke)).join(', ')}. Matched by keyword, so check they say what you mean.`);
|
|
129
|
-
if (found.some((f) => f.kind === 'too few')) out.push('', ` Not enough recent turns to name every pattern yet: each needs ${MIN_TURNS}.`);
|
|
130
|
-
if (found.length) out.push('', ' What the agent did covers your last 90 days, not counting scratch work. Direction, scope, correctness and honesty have no behaviour signal, so they show only what you said.');
|
|
90
|
+
if (!t.spokes) out.push('', ` Too few messages for the 11 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
|
|
91
|
+
appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares }) + '\n');
|
|
131
92
|
out.push('', ` Card: ${p.svg}`);
|
|
132
93
|
if (args.includes('--png')) { const png = pngCard(p.svg); out.push(png ? ` Image to share: ${png}` : ' No image tool here (rsvg-convert or Chrome): open the card in a browser to save an image.'); }
|
|
133
94
|
return out.join('\n');
|
package/status.mjs
CHANGED
|
@@ -137,7 +137,7 @@ if (!isGit) {
|
|
|
137
137
|
} else if (state === 'watching') {
|
|
138
138
|
out.push(` Measuring here · ${hasConfig ? '✓ wired in with a contract (promptwheel.config.json)' : '✓ wired in (zero-config auto-detect)'}. It verifies each commit/turn automatically.`);
|
|
139
139
|
} else if (state === 'ready') {
|
|
140
|
-
out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach
|
|
140
|
+
out.push(` Measuring here · checkable but not wired yet. Try it now: \`sawbuck check\` • make it automatic: \`sawbuck attach\``);
|
|
141
141
|
} else {
|
|
142
142
|
out.push(` Measuring here · ${hasConfig ? 'a contract exists' : 'no test command detected yet'}. \`sawbuck init\` to set up the check, or \`sawbuck context\` for the history alone.`);
|
|
143
143
|
}
|
package/steers.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function steersReport({ json = false, home } = {}) {
|
|
|
37
37
|
const groups = groupSteers(steers);
|
|
38
38
|
const repeated = groups.filter((g) => g.n >= 3 && g.sessions >= 2);
|
|
39
39
|
if (json) return JSON.stringify({ steers: steers.length, scored: stats.scored, unscored: stats.lost, repeated });
|
|
40
|
-
if (!steers.length && !stats.scored) return '🐴 sawbuck: no steers recorded.
|
|
40
|
+
if (!steers.length && !stats.scored) return '🐴 sawbuck: no steers recorded yet. They appear as you work with the plugin\'s live judge on.';
|
|
41
41
|
const out = [`🐴 ${steers.length} steers kept, from ${stats.scored} replies scored${stats.lost ? ` (${stats.lost} could not be scored, so this undercounts)` : ''}. Measured, not estimated.`];
|
|
42
42
|
if (repeated.length) {
|
|
43
43
|
out.push('', 'You keep saying:');
|
package/dispatch.mjs
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// dispatch.mjs, the dispatch: a compact proof-of-work receipt from the results.
|
|
3
|
-
// A receipt, not an ad. Silent when nothing's been recorded to show (unless --force).
|
|
4
|
-
// Zero-dep. Usage: node dispatch.mjs [--repo <path>] [--force] [--json]
|
|
5
|
-
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
|
|
9
|
-
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
|
|
10
|
-
const repo = flag('--repo', process.cwd());
|
|
11
|
-
const force = process.argv.includes('--force');
|
|
12
|
-
|
|
13
|
-
let rec = null;
|
|
14
|
-
try { rec = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); } catch {}
|
|
15
|
-
|
|
16
|
-
const total = rec ? (rec.regressions || 0) + (rec.gamed || 0) + (rec.mandates || 0) : 0;
|
|
17
|
-
if (!rec || (!total && !force)) process.exit(0); // nothing recorded → stay quiet
|
|
18
|
-
if (process.argv.includes('--json')) { console.log(JSON.stringify(rec)); process.exit(0); }
|
|
19
|
-
|
|
20
|
-
const n = (x, s) => `${x} ${s}${x === 1 ? '' : 's'}`;
|
|
21
|
-
const L = rec.latest;
|
|
22
|
-
// Attribute the latest catch to the agent that made it (the Claude hook passes 'claude').
|
|
23
|
-
let head;
|
|
24
|
-
if (L && L.kind === 'gamed') head = L.by ? `🐴 Sawbuck caught ${L.by} gaming a metric` : `🐴 Sawbuck caught a gamed change`;
|
|
25
|
-
else if (L && L.kind === 'regression') head = L.by ? `🐴 Sawbuck caught a regression from ${L.by}` : `🐴 Sawbuck caught a regression`;
|
|
26
|
-
else head = `🐴 Sawbuck`;
|
|
27
|
-
let line = head;
|
|
28
|
-
if (L) {
|
|
29
|
-
const why = String(L.reason || L.kind).replace(/^a change here was gamed,\s*/i, '').replace(/^a change here\s+/i, '');
|
|
30
|
-
line += `\n ${L.file}: ${why}`;
|
|
31
|
-
}
|
|
32
|
-
const tally = [rec.gamed ? n(rec.gamed, 'gamed change') : '', rec.regressions ? n(rec.regressions, 'regression') : ''].filter(Boolean).join(', ');
|
|
33
|
-
if (tally) line += `\n (${tally} caught here so far)`;
|
|
34
|
-
console.log(line);
|