sawbuck 0.1.45 → 0.1.47
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 +8 -8
- package/README.md +33 -29
- package/contract-cmd.mjs +19 -9
- package/debt.mjs +25 -10
- package/exclude.mjs +5 -4
- package/history.mjs +4 -1
- package/init.mjs +7 -6
- package/package.json +3 -2
- package/pipe.mjs +2 -0
- package/reflect.mjs +24 -9
- package/repo-state.mjs +22 -5
- package/rules.mjs +2 -1
- package/sawbuck.mjs +347 -131
- package/shape.mjs +95 -11
- package/status.mjs +13 -1
- package/steers.mjs +2 -2
package/sawbuck.mjs
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// sawbuck, the CLI. Zero-dep.
|
|
3
|
-
// `attach` wires the Claude Code hooks into a repo; `detach` removes them and
|
|
3
|
+
// `attach` wires the Claude Code hooks into a repo; `detach` removes them, and the record stays until `--purge`. The
|
|
4
4
|
// repo never imports Sawbuck. Rides two engines: promptwheel (the check) + @promptwheel/logbook.
|
|
5
5
|
//
|
|
6
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
|
|
7
|
+
// reverses exactly that, and still takes our hooks out when the receipt is lost; `detach --verify` proves the wiring is
|
|
8
|
+
// gone and names what is left; `doctor` runs what each hook runs.
|
|
8
9
|
|
|
10
|
+
import './pipe.mjs';
|
|
9
11
|
import { execFileSync } from 'node:child_process';
|
|
10
12
|
import { erasedBlock, erasedCommits } from './rewrites.mjs';
|
|
11
13
|
import { steersReport } from './steers.mjs';
|
|
12
14
|
import { rulesCli } from './rules.mjs';
|
|
13
15
|
import { shapeCli } from './shape.mjs';
|
|
14
16
|
import { ensureExcluded, clearExcluded, EXCLUDED, RECORD_DIRS } from './exclude.mjs';
|
|
15
|
-
import { repoState } from './repo-state.mjs';
|
|
16
|
-
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync,
|
|
17
|
-
import { basename, join, dirname, resolve } from 'node:path';
|
|
17
|
+
import { repoState, hooksIn, OURS, S_START } from './repo-state.mjs';
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, rmdirSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { basename, join, dirname, resolve, relative } from 'node:path';
|
|
18
20
|
import { fileURLToPath } from 'node:url';
|
|
19
21
|
import { homedir } from 'node:os';
|
|
20
22
|
import { PW } from './bins.mjs';
|
|
@@ -24,18 +26,41 @@ const SELF = fileURLToPath(import.meta.url);
|
|
|
24
26
|
const HERE = dirname(SELF);
|
|
25
27
|
const HOOK = join(HERE, 'sawbuck-hook.mjs');
|
|
26
28
|
// run the engine and parse its JSON verdict (it prints JSON even when it exits nonzero)
|
|
29
|
+
// A commit message is not a person: an agent can write a Sawbuck-Allow trailer, so no sawbuck path honors one
|
|
30
|
+
// (the Stop hook and the git hook do the same through reflect). `--allow` is the declared override.
|
|
27
31
|
function gateReport(repo, a) {
|
|
28
|
-
let o = '', code = 0;
|
|
29
|
-
try { o = execFileSync(process.execPath, [PW, ...a, '--no-record', '--json'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 120000 }); }
|
|
30
|
-
catch (e) { o = (e.stdout || '').toString(); code = e.status || 0; }
|
|
32
|
+
let o = '', err = '', code = 0;
|
|
33
|
+
try { o = execFileSync(process.execPath, [PW, ...a, '--no-record', '--json'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PROMPTWHEEL_NO_ALLOW_TRAILER: '1' }, timeout: +(process.env.SAWBUCK_CHECK_TIMEOUT_MS) || 120000 }); }
|
|
34
|
+
catch (e) { o = (e.stdout || '').toString(); err = e.signal ? 'the check timed out' : (e.stderr || '').toString(); code = e.status || 0; }
|
|
31
35
|
let rep = null; try { rep = JSON.parse(o.slice(o.indexOf('{'))); } catch {}
|
|
32
|
-
|
|
36
|
+
// the engine's own reason, without its zero-config notice
|
|
37
|
+
const why = err.split('\n').filter((l) => l.trim() && !/^no promptwheel\.config\.json, auto-gating/.test(l)).join(' ').trim();
|
|
38
|
+
return { rep, code, why };
|
|
39
|
+
}
|
|
40
|
+
// why nothing could be measured, in words that match the case: no test command, tests red on both sides, or a
|
|
41
|
+
// win the source-only re-run could not apply. `sawbuck init` only where there is no config to edit yet.
|
|
42
|
+
function inconclusiveWhy(rep, repo) {
|
|
43
|
+
const ms = (rep && rep.metrics) || [];
|
|
44
|
+
const m = ms.find((x) => x.inert) || ms[0];
|
|
45
|
+
if (ms.some((x) => x.unprovable)) return 'a win here could not be re-proved, because the source-only re-run did not apply. Do not trust this green on its own.';
|
|
46
|
+
const hasCfg = existsSync(join(repo, 'promptwheel.config.json'));
|
|
47
|
+
let cmd = '';
|
|
48
|
+
try { cmd = String((JSON.parse(readFileSync(join(repo, 'promptwheel.config.json'), 'utf8')).metrics || []).find((x) => x && x.name === (m && m.name))?.cmd || ''); } catch {}
|
|
49
|
+
const placeholder = /set your test command/.test(cmd) || (!hasCfg && !detectedTests(repo));
|
|
50
|
+
const where = hasCfg ? 'in promptwheel.config.json' : 'with `sawbuck init`, then in promptwheel.config.json';
|
|
51
|
+
if (placeholder) return `there is no test command here, so nothing was measured. It finds npm, pytest, cargo and go tests on its own. Set yours ${where} (the cmd of "${(m && m.name) || 'tests_pass'}").`;
|
|
52
|
+
return `"${(m && m.name) || 'tests_pass'}" failed before and after this change, so there was no win or loss to measure. If your tests are red, fix them first. If they pass for you, the clean copy the check runs in is missing something: set setup or linkDirs ${where}.`;
|
|
53
|
+
}
|
|
54
|
+
// the test commands the engine finds with no config (promptwheel's detectTestCmd)
|
|
55
|
+
function detectedTests(repo) {
|
|
56
|
+
const has = (f) => existsSync(join(repo, f));
|
|
57
|
+
if (['go.mod', 'Cargo.toml', 'pyproject.toml', 'setup.py', 'pytest.ini'].some(has)) return true;
|
|
58
|
+
try { return !!JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).scripts?.test; } catch { return false; }
|
|
33
59
|
}
|
|
34
60
|
// is the metric live (actually ran), or inert (never ran in isolation)?
|
|
35
61
|
const metricLive = (m) => !!m && !m.inert && m.status !== 'unmeasurable' && m.before != null;
|
|
36
|
-
const S_START = '# >>> sawbuck >>>';
|
|
37
62
|
const S_END = '# <<< sawbuck <<<';
|
|
38
|
-
const BLOCK_RE = new RegExp(
|
|
63
|
+
const BLOCK_RE = new RegExp(`${S_START}[\\s\\S]*?${S_END}\\n?`, 'g'); // exactly what attach --git puts in, so taking it out leaves the bytes that were there
|
|
39
64
|
// the record/state dirs the engines write into the worktree; excluded locally so
|
|
40
65
|
// attach never dirties `git status`.
|
|
41
66
|
// The record Sawbuck keeps on disk but must never let dirty git status: the engine records
|
|
@@ -63,23 +88,35 @@ Usage:
|
|
|
63
88
|
commit, or the range you name). Zero-config, no attach, no hook. The
|
|
64
89
|
fastest way to see it work. Every promptwheel run flag passes through.
|
|
65
90
|
Exit 0 verified · 1 regression · 2 gamed · 3 inconclusive · 64 could not run
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
(--working with nothing uncommitted is 64 too)
|
|
92
|
+
sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block] [--force]
|
|
93
|
+
Write promptwheel.config.json: your detected tests and the tripwires, with
|
|
94
|
+
notes from what your git history shows actually breaks, plus a short guide in
|
|
95
|
+
AGENTS.md (--no-agent-block to skip). Wizard in a terminal, non-interactive in
|
|
96
|
+
a pipe/CI (or --yes). An existing config needs --force.
|
|
97
|
+
sawbuck attach [repo] [--user | --shared | --git] [--dry-run]
|
|
98
|
+
Check every Claude Code turn in this repo. --user: every repo that has a
|
|
99
|
+
promptwheel.config.json. --shared: a tracked settings file a team commits.
|
|
100
|
+
--git: a post-commit hook, for any other agent or none (commits in a linked
|
|
101
|
+
worktree are not checked). --dry-run prints every file it would write
|
|
102
|
+
sawbuck detach [repo] [--user] [--all] [--verify] [--purge] [--dry-run]
|
|
103
|
+
Remove the hooks (no flag: whatever is wired here; --all: every repo). The
|
|
104
|
+
record stays, kept out of git status; --purge deletes it. --verify proves the
|
|
105
|
+
wiring is gone and names what is left. --dry-run lists what would go
|
|
74
106
|
sawbuck status [repo] The inventory: caught over time + debt accruing now + whether it is armed. --json
|
|
75
107
|
--log [-n N] for the recent checks, newest first
|
|
76
108
|
sawbuck debt [repo] [--since REF] [--fail-on undocumented|test|any|N]
|
|
77
109
|
The live suppression debt in HEAD, risk-ranked, undocumented flagged. --since REF
|
|
78
|
-
shows only what a branch adds; --fail-on exits
|
|
110
|
+
shows only what a branch adds; --fail-on exits 1 when that debt (or, without
|
|
111
|
+
--since, any live debt of that kind) is there (a CI gate)
|
|
79
112
|
sawbuck context [repo] Relevant history for the files you are changing (reverts, weakened tests, hot spots)
|
|
80
|
-
sawbuck doctor [repo]
|
|
81
|
-
|
|
82
|
-
|
|
113
|
+
sawbuck doctor [repo] Run what each hook runs (project and user level) and say if it still works,
|
|
114
|
+
is incomplete, or is gone. --all lists every repo it is attached to and checks each
|
|
115
|
+
sawbuck contract [--dry-run] [--full] [--strict]
|
|
116
|
+
Give this repo its rules: protected paths, the written rule, and your detected
|
|
117
|
+
tests when there is no config yet (--dry-run to preview). --full adds supply-chain
|
|
118
|
+
guards, the repo lock, and a CI check on a github.com repo with a real test
|
|
119
|
+
command; --strict makes them block
|
|
83
120
|
sawbuck rules --prepare Gather your messages on this machine so an agent can propose the rules you never wrote
|
|
84
121
|
sawbuck rules --check F Keep only proposed rules with 2 quotes that are really yours
|
|
85
122
|
sawbuck shape --prepare Gather a sample of your messages so an agent can label what you push on
|
|
@@ -102,6 +139,11 @@ const repoRoot = () => gitTop(repoArg()) || repoArg();
|
|
|
102
139
|
function gitPath(repo, rel) { try { return resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-path', rel], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return join(repo, '.git', rel); } }
|
|
103
140
|
function gitDir(repo) { try { return resolve(repo, execFileSync('git', ['-C', repo, 'rev-parse', '--git-dir'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()); } catch { return join(repo, '.git'); } }
|
|
104
141
|
const out = (s) => process.stdout.write(s.endsWith('\n') ? s : s + '\n');
|
|
142
|
+
// `detach --dry-run` walks the same path as the real detach and lists what it would touch, so the two cannot drift.
|
|
143
|
+
// What a dry run "removed" reads as gone for the rest of the walk, so the steps that follow decide as they would for real.
|
|
144
|
+
const planned = { lines: new Map(), gone: new Set(), receipts: new Map(), registry: null };
|
|
145
|
+
const there = (p) => existsSync(p) && !planned.gone.has(p);
|
|
146
|
+
const say = (s) => { if (!dry) out(s); };
|
|
105
147
|
|
|
106
148
|
if (cmd === '--version' || cmd === '-v') { console.log(version); process.exit(0); }
|
|
107
149
|
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
@@ -115,11 +157,29 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
115
157
|
process.exit(0);
|
|
116
158
|
}
|
|
117
159
|
|
|
160
|
+
// `sawbuck <command> --help`: that command's lines from the help, and nothing runs. Internal commands get the whole help.
|
|
161
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
162
|
+
const own = []; let cur = null;
|
|
163
|
+
for (const l of help.split('\n')) { const m = l.match(/^ sawbuck (\S+)/); if (m) cur = m[1]; else if (!/^ {20,}\S/.test(l)) cur = null; if (cur === cmd) own.push(l); }
|
|
164
|
+
if (own.length || ['rank', 'frame', 'turn', '__hook'].includes(cmd)) { out(own.length ? `sawbuck ${version}\n\n${own.join('\n')}` : help); process.exit(0); }
|
|
165
|
+
}
|
|
166
|
+
// a flag a command does not know is an error, never a silent no-op. `check` is not here: it passes its flags to the
|
|
167
|
+
// engine, which refuses the ones it does not know. --claude is what older installs pass: taken, not listed.
|
|
168
|
+
const KNOWN = {
|
|
169
|
+
contract: ['--dry-run', '--full', '--strict'],
|
|
170
|
+
init: ['--yes', '-y', '--wizard', '--no-investigate', '--no-agent-block', '--force', '--preset'],
|
|
171
|
+
attach: ['--user', '--shared', '--git', '--dry-run', '--claude'],
|
|
172
|
+
detach: ['--user', '--all', '--verify', '--purge', '--dry-run', '--claude'],
|
|
173
|
+
doctor: ['--all'],
|
|
174
|
+
status: ['--json', '--log', '-n'],
|
|
175
|
+
};
|
|
176
|
+
if (KNOWN[cmd]) { const bad = rest.filter((a) => a.startsWith('-') && !KNOWN[cmd].includes(a)); if (bad.length) usage(`unknown flag ${bad.join(' ')} for ${cmd} (it takes ${KNOWN[cmd].filter((f) => f !== '--claude').join(', ')})`); }
|
|
177
|
+
|
|
118
178
|
switch (cmd) {
|
|
119
179
|
case 'contract': {
|
|
120
180
|
const top = gitTop(repoArg());
|
|
121
181
|
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) =>
|
|
182
|
+
try { execFileSync(process.execPath, [join(HERE, 'contract-cmd.mjs'), top, ...rest.filter((a) => KNOWN.contract.includes(a))], { stdio: 'inherit' }); }
|
|
123
183
|
catch (e) { process.exit(e.status || 1); }
|
|
124
184
|
break;
|
|
125
185
|
}
|
|
@@ -131,14 +191,14 @@ switch (cmd) {
|
|
|
131
191
|
case 'status': {
|
|
132
192
|
// the inventory: one view unioning what the check has caught over time, what debt is accruing
|
|
133
193
|
// now, and whether it is armed here. The "show me everything" face. --log is the recent feed.
|
|
134
|
-
if (flags.has('--log')) { logReport(
|
|
135
|
-
try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo',
|
|
194
|
+
if (flags.has('--log')) { logReport(repoRoot(), rest); break; }
|
|
195
|
+
try { execFileSync(process.execPath, [join(HERE, 'status.mjs'), '--repo', repoRoot(), ...rest.filter((a) => a.startsWith('--'))], { stdio: 'inherit' }); }
|
|
136
196
|
catch (e) { process.exit(e.status || 1); }
|
|
137
197
|
break;
|
|
138
198
|
}
|
|
139
199
|
case 'check': {
|
|
140
200
|
// one-shot: gate your latest change right now (working tree if dirty, else the last commit),
|
|
141
|
-
// zero-config (the engine
|
|
201
|
+
// zero-config (the engine finds npm, pytest, cargo and go tests), and say plainly whether the win was
|
|
142
202
|
// earned. No attach, no hook, no record. The try-it-first on-ramp.
|
|
143
203
|
// `sawbuck check` IS `promptwheel run` with a plain-spoken verdict: every engine flag passes
|
|
144
204
|
// through (--base/--head/--repeat/--allow/--json/--no-detect-gaming), so there is one entry
|
|
@@ -148,7 +208,7 @@ switch (cmd) {
|
|
|
148
208
|
for (let i = 0; i < rest.length; i++) {
|
|
149
209
|
const a = rest[i];
|
|
150
210
|
if (a === '--json') { json = true; continue; }
|
|
151
|
-
if (VALUE_FLAGS.has(a)) { const v = rest[++i]; if (v == null)
|
|
211
|
+
if (VALUE_FLAGS.has(a)) { const v = rest[++i]; if (v == null) usage(`${a} needs a value`); if (a === '--base') base = v; else if (a === '--head') head = v; else pass.push(a, v); continue; }
|
|
152
212
|
if (a.startsWith('--')) { pass.push(a); continue; }
|
|
153
213
|
pos.push(a);
|
|
154
214
|
}
|
|
@@ -159,24 +219,33 @@ switch (cmd) {
|
|
|
159
219
|
// 64 (EX_USAGE) for every "could not run" below, never a verdict code: a script or CI must not read it as gamed (2).
|
|
160
220
|
if (!g('rev-parse', '--git-dir')) { out(`🐴 sawbuck: ${repo} is not a git repo. The check re-proves a change against git history, so run it inside one (git init, then commit).`); process.exit(64); }
|
|
161
221
|
if (!g('rev-parse', '--verify', 'HEAD')) { out('🐴 sawbuck: no commit yet. Make one, then `sawbuck check` verifies your next change against it.'); process.exit(64); }
|
|
162
|
-
const
|
|
222
|
+
const uncommitted = g('status', '--porcelain').length > 0;
|
|
223
|
+
const dirty = !base && !head && uncommitted;
|
|
224
|
+
// --working on a clean tree would read the tree against the commit it already is, and call that held
|
|
225
|
+
if (pass.includes('--working') && !uncommitted) { out('🐴 sawbuck: nothing uncommitted to check, the working tree is clean. `sawbuck check` verifies the last commit.'); process.exit(64); }
|
|
163
226
|
// nothing to verify yet: a clean tree with no previous commit to diff against (a fresh repo). Say so
|
|
164
227
|
// plainly instead of the generic "could not run" below, so the first run is not a dead end.
|
|
165
228
|
if (!dirty && !base && !g('rev-parse', '--verify', 'HEAD~1')) { out('🐴 sawbuck: nothing to verify yet, the working tree is clean and this is the only commit. Make a change (or add one more commit), then `sawbuck check` re-proves the win from your source edits alone.'); process.exit(64); }
|
|
166
229
|
const where = dirty ? 'working' : (base || head) ? `${base || 'HEAD~1'}..${head || 'HEAD'}` : 'last';
|
|
167
230
|
const args = dirty ? ['run', '--working'] : ['run', '--base', base || 'HEAD~1', '--head', head || 'HEAD'];
|
|
168
|
-
const { rep, code } = gateReport(repo, [...args, ...pass]);
|
|
169
|
-
if (!rep || !rep.verdict) { out(
|
|
231
|
+
const { rep, code, why } = gateReport(repo, [...args, ...pass]);
|
|
232
|
+
if (!rep || !rep.verdict) { out(`🐴 sawbuck: could not run the check here: ${why || 'the engine gave no verdict'}`); process.exit(64); }
|
|
170
233
|
if (json) { out(JSON.stringify(rep)); process.exit(code); }
|
|
171
234
|
const m = (rep.metrics || [])[0];
|
|
172
235
|
for (const md of rep.mandates || []) out(`🐴 ⛔ protected path "${md.name}" touched${md.reason ? ': ' + md.reason : ''} (touched: ${(md.files || []).join(', ')})${md.removed ? ' [deleted in this same change; the rule stays in force for the change that removes it]' : ''}`);
|
|
173
236
|
const win = (rep.metrics || []).find((x) => x.status === 'improved' && !x.tripwire);
|
|
174
|
-
|
|
237
|
+
const noDetect = pass.includes('--no-detect-gaming');
|
|
238
|
+
if (rep.verdict === 'pass' && rep.allowed) out(`🐴 ✎ allowed with --allow: "${rep.allowed.reason}". That is a person's call, not a proof: ${(rep.allowed.cleared || []).join(', ') || 'the change'} was let through without a source-only re-proof.`);
|
|
239
|
+
else if (rep.verdict === 'pass' && win && noDetect) out(`🐴 ✓ passed: "${win.name}" improved, but gaming detection is off (--no-detect-gaming), so the win was not re-proved from the source.`);
|
|
240
|
+
else 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).`);
|
|
175
241
|
else if (rep.verdict === 'pass') out(`🐴 ✓ held: nothing got worse in your ${where} change, and there was no win to re-prove.`);
|
|
176
|
-
else if (rep.verdict === 'gamed')
|
|
177
|
-
|
|
242
|
+
else if (rep.verdict === 'gamed') {
|
|
243
|
+
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.');
|
|
244
|
+
if (!dirty && /^(Sawbuck|Promptwheel)-Allow:/im.test(g('log', '--format=%B', `${base || 'HEAD~1'}..${head || 'HEAD'}`))) out('🐴 note: a Sawbuck-Allow trailer does not clear this, because an agent can write a commit message. A person passes --allow "<reason>".');
|
|
245
|
+
}
|
|
246
|
+
else if (rep.verdict === 'fail' && (rep.mandates || []).length) out('🐴 ✗ sent back: a protected path changed, however green the rest is.');
|
|
178
247
|
else if (rep.verdict === 'fail') out('🐴 ✗ sent back: this change made something worse.');
|
|
179
|
-
else out(
|
|
248
|
+
else out(`🐴 ≈ inconclusive: ${inconclusiveWhy(rep, repo)}`);
|
|
180
249
|
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.');
|
|
181
250
|
for (const x of rep.explained || []) out(`🐴 ${x.noted ? 'note' : 'needs a look'}: ${x.what}${x.items ? ': ' + x.items.join(', ') : ''}${!x.noted && x.allowable ? '. If you meant this: --allow "<reason>", or on a pull request comment sawbuck-allow: <reason>' : ''}`);
|
|
182
251
|
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.`);
|
|
@@ -233,7 +302,9 @@ switch (cmd) {
|
|
|
233
302
|
{ // a baseline even with no rules yet: a turn that signs a contract is judged by the contract it began with
|
|
234
303
|
const prev = st.turnBaseline && typeof st.turnBaseline === 'object' ? st.turnBaseline : null;
|
|
235
304
|
const stoppedSince = st.lastStopAt && st.lastPromptAt && Date.parse(st.lastStopAt) >= Date.parse(st.lastPromptAt);
|
|
236
|
-
|
|
305
|
+
// a baseline attach wrote is not a prompt: it covers the turn that ran attach, if there was one. Set up from
|
|
306
|
+
// a terminal, nothing is in flight, and the commits made before this prompt are the person's.
|
|
307
|
+
const unfinished = !!prev && !!st.lastPromptAt && !stoppedSince && prev.by !== 'attach';
|
|
237
308
|
if (!unfinished) {
|
|
238
309
|
const flagged = (st.lastViolation && st.lastViolation.files && typeof st.lastViolation.files === 'object') ? st.lastViolation.files : {};
|
|
239
310
|
const before = (st.lastViolation && st.lastViolation.before && typeof st.lastViolation.before === 'object') ? st.lastViolation.before : {};
|
|
@@ -272,32 +343,41 @@ switch (cmd) {
|
|
|
272
343
|
break;
|
|
273
344
|
}
|
|
274
345
|
case 'attach': {
|
|
275
|
-
const user = flags.has('--user');
|
|
346
|
+
const user = flags.has('--user'), shared = flags.has('--shared'), git = flags.has('--git');
|
|
347
|
+
if (user + shared + git > 1) usage('attach takes one of --user, --shared or --git');
|
|
276
348
|
const top = gitTop(repoArg());
|
|
277
349
|
if (!top && !user) usage(`${repoArg()} is not a git repository (attach wires one; run it inside your project, or name its path)`);
|
|
278
350
|
const repo = top || repoArg();
|
|
279
|
-
wireClaude(repo, { user });
|
|
351
|
+
if (git) wireGit(repo); else wireClaude(repo, { user, shared });
|
|
280
352
|
if (!dry) verifyAttach(repo);
|
|
281
353
|
break;
|
|
282
354
|
}
|
|
283
355
|
case 'detach': {
|
|
284
356
|
const purge = flags.has('--purge');
|
|
285
|
-
|
|
286
|
-
if (flags.has('--user')) unwireClaude(repo, { user: true });
|
|
287
|
-
else if (flags.has('--claude')) unwireClaude(repo, {});
|
|
357
|
+
if (flags.has('--all')) detachAll(purge);
|
|
288
358
|
else {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
359
|
+
const repo = repoRoot();
|
|
360
|
+
if (flags.has('--user')) unwireClaude(repo, { user: true });
|
|
361
|
+
else if (flags.has('--claude')) unwireClaude(repo, {});
|
|
362
|
+
else {
|
|
363
|
+
// no flag: take out whatever is wired in this repo, older git hooks included. The receipt says what attach
|
|
364
|
+
// wrote; our hooks in the project settings or the post-commit hook count without one, so a lost receipt or a
|
|
365
|
+
// teammate's clone of a --shared repo is not left wired.
|
|
366
|
+
const a = readManifest(manifestPath(repo)).attachments || {};
|
|
367
|
+
const hasClaude = !!(a.claude || a['claude-shared']) || [false, true].some((shared) => hooksIn(claudeFile(repo, { shared }).f));
|
|
368
|
+
if (hasClaude) unwireClaude(repo, {});
|
|
369
|
+
if (a.git || hasBlock(repo) || !hasClaude) unwireGit(repo);
|
|
370
|
+
}
|
|
371
|
+
if (purge) purgeRecord(repo);
|
|
372
|
+
syncExclude(repo, readManifest(manifestPath(repo))); // after a purge the exclude lines go too
|
|
373
|
+
const left = recordLeft(repo);
|
|
374
|
+
if (left.length) say(`sawbuck: still on disk: ${left.join(', ')}. It is the record, kept out of git status; \`sawbuck detach --purge\` removes it.`);
|
|
375
|
+
if (flags.has('--verify') && !dry) verifyClean(repo);
|
|
295
376
|
}
|
|
296
|
-
if (
|
|
297
|
-
if (flags.has('--verify')) verifyClean(repo);
|
|
377
|
+
if (dry) out(dryText([...planned.lines.values()], 'remove'));
|
|
298
378
|
break;
|
|
299
379
|
}
|
|
300
|
-
case 'doctor': { doctorRepo(repoRoot()); break; }
|
|
380
|
+
case 'doctor': { if (flags.has('--all')) doctorAll(); else doctorRepo(repoRoot()); break; }
|
|
301
381
|
case 'rules': { out(rulesCli(rest)); break; }
|
|
302
382
|
case 'shape': { out(shapeCli(rest)); break; }
|
|
303
383
|
case 'steers': { out(steersReport({ json: flags.has('--json') })); break; }
|
|
@@ -388,51 +468,139 @@ function verifyAttach(repo) {
|
|
|
388
468
|
const { rep } = gateReport(repo, ['run', '--base', base, '--head', 'HEAD']);
|
|
389
469
|
const m = rep && (rep.metrics || [])[0];
|
|
390
470
|
if (metricLive(m)) out(` ✓ I can measure "${m.name}" here. On each commit I'll verify a win survived a source-only re-run, and flag it if it did not.`);
|
|
391
|
-
else out(
|
|
471
|
+
else out(` ⚠ I could not measure anything here yet: ${rep ? inconclusiveWhy(rep, repo) : 'the check did not run'} I stay quiet until then.`);
|
|
392
472
|
}
|
|
393
473
|
|
|
394
474
|
// --- the attach receipt (out of the worktree, inside .git) ------------------
|
|
395
475
|
function manifestPath(repo) { return join(gitDir(repo), 'sawbuck', 'attach.json'); }
|
|
396
476
|
function userManifestPath() { return join(homedir(), '.sawbuck', 'user-attach.json'); }
|
|
397
|
-
function readManifest(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { version: 1, attachments: {} }; } }
|
|
477
|
+
function readManifest(p) { if (planned.receipts.has(p)) return planned.receipts.get(p); try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return { version: 1, attachments: {} }; } }
|
|
398
478
|
function writeManifest(p, m) {
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
479
|
+
if (dry) planned.receipts.set(p, m);
|
|
480
|
+
if (!Object.keys(m.attachments || {}).length) { dropFile(p, 'the attach receipt'); rmIfEmptyDir(dirname(p)); return; }
|
|
481
|
+
putFile(p, JSON.stringify(m, null, 2) + '\n', 'the attach receipt');
|
|
402
482
|
}
|
|
403
483
|
|
|
404
|
-
|
|
405
|
-
function
|
|
406
|
-
|
|
484
|
+
// Every write and removal on the detach path goes through these two: under --dry-run each is listed instead of done.
|
|
485
|
+
function putFile(f, text, what) {
|
|
486
|
+
if (dry) { planned.lines.set(f, `modify ${f} (${what})`); return; }
|
|
487
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
488
|
+
writeFileSync(f, text);
|
|
489
|
+
}
|
|
490
|
+
function dropFile(p, what) {
|
|
491
|
+
if (!there(p)) return;
|
|
492
|
+
if (dry) { planned.lines.set(p, `delete ${p} (${what})`); planned.gone.add(p); return; }
|
|
493
|
+
try { rmSync(p, { recursive: true, force: true }); } catch {}
|
|
494
|
+
}
|
|
495
|
+
function rmIfEmptyDir(d) { if (dry) return; try { if (existsSync(d) && readdirSync(d).length === 0) rmdirSync(d); } catch {} }
|
|
407
496
|
// --- .git/info/exclude: keep the worktree clean without touching .gitignore --
|
|
408
497
|
// Any attachment keeps the block, the last one out takes it. Returns whether the file changed.
|
|
409
498
|
function excludeFile(repo) { return gitPath(repo, 'info/exclude'); }
|
|
410
|
-
|
|
499
|
+
// The lines stay while the record they hide is still on disk: clearing them would put .sawbuck/ in git status and a
|
|
500
|
+
// signed repo would fail its own hooks-frozen rule over Sawbuck's record. --purge deletes the record, then the lines.
|
|
501
|
+
function syncExclude(repo, manifest) {
|
|
502
|
+
const changed = Object.keys(manifest.attachments || {}).length ? ensureExcluded(repo, dry) : hiddenLeft(repo).length ? false : clearExcluded(repo, dry);
|
|
503
|
+
if (dry && changed) planned.lines.set(excludeFile(repo), `modify ${excludeFile(repo)} (the lines that keep the record out of git status)`);
|
|
504
|
+
return changed;
|
|
505
|
+
}
|
|
506
|
+
// the record paths still on disk and not tracked (a committed LOGBOOK.md is the person's file)
|
|
507
|
+
function recordLeft(repo) {
|
|
508
|
+
const tracked = (p) => { try { return !!execFileSync('git', ['-C', repo, 'ls-files', '--', p], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return false; } };
|
|
509
|
+
return ['.sawbuck/', '.promptwheel/outcomes.jsonl', 'events.jsonl', 'annotations.jsonl', 'LOGBOOK.md', 'JOURNEY.md'].filter((p) => there(join(repo, p)) && !tracked(p));
|
|
510
|
+
}
|
|
511
|
+
// what the block hides that is still there: the record, and a settings.local.json the person keeps
|
|
512
|
+
function hiddenLeft(repo) {
|
|
513
|
+
const local = join(repo, '.claude', 'settings.local.json');
|
|
514
|
+
let own = false; try { own = there(local) && !execFileSync('git', ['-C', repo, 'ls-files', '--', '.claude/settings.local.json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
|
|
515
|
+
return [...recordLeft(repo), ...(own ? ['.claude/settings.local.json'] : [])];
|
|
516
|
+
}
|
|
411
517
|
|
|
412
|
-
// --- git post-commit
|
|
413
|
-
//
|
|
518
|
+
// --- git post-commit (attach --git) -----------------------------------------
|
|
519
|
+
// For any agent or none: every commit is checked, whatever made it. The hook prefers a `sawbuck` on PATH (so it
|
|
520
|
+
// survives a reinstall), then the absolute path of the sawbuck that attached, then the same version through npx.
|
|
521
|
+
function hookBlock(repo) {
|
|
522
|
+
const r = JSON.stringify(repo);
|
|
523
|
+
// git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE into its hooks. The check shells out to git and would
|
|
524
|
+
// inherit them, operate on the wrong repo, and silently catch nothing. Clear them first, in a subshell: the block
|
|
525
|
+
// sits above the person's own hook lines, and those keep what git gave them.
|
|
526
|
+
return `${S_START}\n(unset 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}; elif [ -f ${JSON.stringify(SELF)} ]; then ${nodeCmd()} ${JSON.stringify(SELF)} __hook --repo ${r}; else npx -y sawbuck@${version} __hook --repo ${r}; fi) || true\n${S_END}\n`;
|
|
527
|
+
}
|
|
528
|
+
// The first commit after attach is checked only if there is a commit to check it from: HEAD as it stands now.
|
|
529
|
+
// A baseline other hooks are keeping (Claude hooks wired here or at user level, a turn in flight) is left alone;
|
|
530
|
+
// one left behind by an attachment that is gone is not, or commits made while nothing watched would be laid on the next one.
|
|
531
|
+
function gitBaseline(repo) {
|
|
532
|
+
let sha = ''; try { sha = execFileSync('git', ['-C', repo, 'rev-parse', '--verify', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
|
|
533
|
+
const path = join(repo, '.sawbuck', 'state.json');
|
|
534
|
+
let st = {}; try { st = JSON.parse(readFileSync(path, 'utf8')); } catch {}
|
|
535
|
+
const kept = (st.lastSha || (st.turnBaseline && st.turnBaseline.sha)) && (Object.keys(readManifest(manifestPath(repo)).attachments || {}).length || hooksIn(claudeFile(repo, { user: true }).f));
|
|
536
|
+
return { path, st, sha: kept ? '' : sha };
|
|
537
|
+
}
|
|
538
|
+
function wireGit(repo) {
|
|
539
|
+
const pc = gitPath(repo, 'hooks/post-commit');
|
|
540
|
+
// core.hooksPath inside the work tree (the husky way): the team tracks those files or a hook tool rewrites them, and
|
|
541
|
+
// our block pins this machine's paths. Refuse, and give the portable line a person can add.
|
|
542
|
+
if (!relative(repo, pc).startsWith('..') && relative(gitDir(repo), pc).startsWith('..')) usage(`git hooks here live in ${relative(repo, dirname(pc))}/, inside the work tree (core.hooksPath), where the team or a hook tool owns the files, so attach --git writes nothing. Use plain \`sawbuck attach\`, or add this line by hand to the post-commit hook kept there (with husky: .husky/post-commit): (unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_COMMON_DIR; npx -y sawbuck@${version} __hook --repo "$PWD") || true`);
|
|
543
|
+
const existed = existsSync(pc);
|
|
544
|
+
const was = existed ? readFileSync(pc, 'utf8') : '';
|
|
545
|
+
if (was.includes(S_START)) { out(`sawbuck: already attached (git) in ${repo}`); return; }
|
|
546
|
+
// our block is shell, and git only runs an executable hook: a hook in another language, or one git is not running,
|
|
547
|
+
// is left exactly as it is
|
|
548
|
+
if (existed && was.startsWith('#!') && !/^#!\s*\S*\/(env\s+)?(ba|da|z|k)?sh\b/.test(was.split('\n')[0])) usage(`the post-commit hook here is not a shell script (${was.split('\n')[0]}). Add this line to it by hand: npx -y sawbuck@${version} __hook`);
|
|
549
|
+
if (existed && process.platform !== 'win32') { let x = false; try { x = !!(statSync(pc).mode & 0o111); } catch {} if (!x) usage(`the post-commit hook here is not executable, so git does not run it. Make it executable (chmod +x ${relative(repo, pc)}) or remove it, then run attach --git again.`); }
|
|
550
|
+
// Our block goes right after the shebang line, so a hook that ends in `exit 0` still reaches it. The receipt notes
|
|
551
|
+
// the shebang or the newline we had to add, so detach leaves a hook that was here before byte for byte as it was.
|
|
552
|
+
const shebang = !was.startsWith('#!'), nl = !shebang && !was.includes('\n');
|
|
553
|
+
const body = (shebang ? '#!/bin/sh\n' : '') + was + (nl ? '\n' : '');
|
|
554
|
+
const at = body.indexOf('\n') + 1;
|
|
555
|
+
const mp = manifestPath(repo), base = gitBaseline(repo);
|
|
556
|
+
if (dry) {
|
|
557
|
+
out(dryText([
|
|
558
|
+
`${existed ? 'modify' : 'create'} ${pc} (post-commit hook block)`,
|
|
559
|
+
ensureExcluded(repo, true) ? `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})` : null,
|
|
560
|
+
`${existsSync(mp) ? 'modify' : 'create'} ${mp} (attach receipt)`,
|
|
561
|
+
base.sha ? `write ${base.path} (the commit to check from, so the first commit after this is checked)` : null,
|
|
562
|
+
`write ${regPath()} (the list of attached repos, for detach --all)`,
|
|
563
|
+
].filter(Boolean)));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
writeFileSync(pc, body.slice(0, at) + hookBlock(repo) + body.slice(at));
|
|
567
|
+
chmodSync(pc, 0o755);
|
|
568
|
+
const m = readManifest(mp);
|
|
569
|
+
m.attachments = { ...m.attachments, git: { pc: { created: !existed, ...(existed && shebang ? { shebang } : {}), ...(nl ? { nl } : {}) } } };
|
|
570
|
+
syncExclude(repo, m);
|
|
571
|
+
writeManifest(mp, m);
|
|
572
|
+
if (base.sha) { try { mkdirSync(dirname(base.path), { recursive: true }); writeFileSync(base.path, JSON.stringify({ turns: 0, shownTotal: 0, ...base.st, lastSha: base.sha }, null, 2) + '\n'); } catch {} }
|
|
573
|
+
regAdd(repo, 'git');
|
|
574
|
+
out(`sawbuck: attached (git post-commit) in ${repo}. Every commit is checked, whatever made it.`);
|
|
575
|
+
}
|
|
576
|
+
function hasBlock(repo) { try { return readFileSync(gitPath(repo, 'hooks/post-commit'), 'utf8').includes(S_START); } catch { return false; } }
|
|
414
577
|
function unwireGit(repo) {
|
|
415
578
|
const pc = gitPath(repo, 'hooks/post-commit');
|
|
416
579
|
const m = readManifest(manifestPath(repo));
|
|
417
|
-
const
|
|
580
|
+
const rec = m.attachments?.git;
|
|
581
|
+
const wired = hasBlock(repo);
|
|
418
582
|
// signing with no attach still hides the record, so a repo with no receipt can have a block to take back
|
|
419
|
-
if (!
|
|
420
|
-
if (
|
|
421
|
-
|
|
422
|
-
|
|
583
|
+
if (!wired && !rec) { say(syncExclude(repo, m) ? `sawbuck: removed the record lines from ${excludeFile(repo)}` : 'sawbuck: nothing to detach'); return; }
|
|
584
|
+
if (wired) {
|
|
585
|
+
// take out what attach put in, and nothing else: a hook that was here before is the same bytes again
|
|
586
|
+
let rest = readFileSync(pc, 'utf8').replace(BLOCK_RE, '');
|
|
587
|
+
if (rec?.pc?.shebang && rest.startsWith('#!/bin/sh\n')) rest = rest.slice('#!/bin/sh\n'.length);
|
|
588
|
+
if (rec?.pc?.nl && rest.endsWith('\n')) rest = rest.slice(0, -1);
|
|
423
589
|
// true inverse: if we created this hook and it is trivial again, remove it
|
|
424
|
-
if ((created || !
|
|
425
|
-
else
|
|
590
|
+
if ((rec?.pc?.created || !rec) && isTrivialHook(rest)) dropFile(pc, 'the post-commit hook attach created');
|
|
591
|
+
else putFile(pc, rest, 'our block in the post-commit hook');
|
|
426
592
|
}
|
|
427
|
-
if (
|
|
428
|
-
|
|
593
|
+
if (rec) { delete m.attachments.git; syncExclude(repo, m); writeManifest(manifestPath(repo), m); }
|
|
594
|
+
regRemove(repo, 'git');
|
|
595
|
+
say(`sawbuck: detached (git) from ${repo}`);
|
|
429
596
|
}
|
|
430
597
|
function isTrivialHook(text) { return text.replace(/^#!.*\n?/, '').trim() === ''; }
|
|
431
598
|
|
|
432
599
|
// --- Claude Code hooks ------------------------------------------------------
|
|
433
600
|
// user-level hooks (~/.claude) target the repo by cwd; project hooks pin the repo.
|
|
434
|
-
// Project default writes the gitignored settings.local.json
|
|
435
|
-
//
|
|
601
|
+
// Project default writes the gitignored settings.local.json. --shared writes the tracked settings.json for a team:
|
|
602
|
+
// its hooks run the pinned version through npx and find the repo from where they run, so the file holds no path from
|
|
603
|
+
// this machine, and removing the hooks shows in git. Safe merge; refuses malformed JSON; idempotent.
|
|
436
604
|
function claudeFile(repo, { user, shared }) {
|
|
437
605
|
const dir = join(user ? homedir() : repo, '.claude');
|
|
438
606
|
const name = user || shared ? 'settings.json' : 'settings.local.json';
|
|
@@ -446,12 +614,12 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
|
|
|
446
614
|
return `"$(command -v node || echo ${JSON.stringify(execPath).replace(/^"|"$/g, "'")})"`;
|
|
447
615
|
}
|
|
448
616
|
function wireClaude(repo, opts) {
|
|
449
|
-
const { user } = opts;
|
|
617
|
+
const { user, shared } = opts;
|
|
450
618
|
// user-level hooks already cover every repo; a second set would run each check twice and spend the
|
|
451
619
|
// send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
|
|
452
|
-
if (!user) {
|
|
620
|
+
if (!user && !shared) {
|
|
453
621
|
const uf = claudeFile(repo, { user: true }).f;
|
|
454
|
-
try { if (readFileSync(uf, 'utf8')
|
|
622
|
+
try { if (OURS.test(readFileSync(uf, 'utf8'))) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add${existsSync(join(repo, 'promptwheel.config.json')) ? '' : '. The check runs here once this repo has its rules: sawbuck contract'}`); return; } } catch {}
|
|
455
623
|
}
|
|
456
624
|
const { dir, f } = claudeFile(repo, opts);
|
|
457
625
|
const dirExisted = existsSync(dir);
|
|
@@ -461,30 +629,41 @@ function wireClaude(repo, opts) {
|
|
|
461
629
|
// run through npx, the installed path lives in a cache that can be cleared, and a missing hook fails quietly and
|
|
462
630
|
// leaves the wall off: fall back to fetching the same version
|
|
463
631
|
const run = (args) => {
|
|
632
|
+
if (shared) return `npx -y sawbuck@${version} ${args}`;
|
|
464
633
|
const direct = `${nodeCmd()} ${JSON.stringify(SELF)} ${args}`;
|
|
465
634
|
return process.platform !== 'win32' && SELF.includes('/_npx/') ? `if [ -f ${JSON.stringify(SELF)} ]; then ${direct}; else npx -y sawbuck@${version} ${args}; fi` : direct;
|
|
466
635
|
};
|
|
467
|
-
const
|
|
468
|
-
const
|
|
636
|
+
const here = user || shared; // no repo path in the command: the hook works out the repo from where it runs
|
|
637
|
+
const ctx = run(here ? 'turn --hook' : `turn --hook ${JSON.stringify(repo)}`);
|
|
638
|
+
const chk = run(here ? '__hook --by claude --stop' : `__hook --repo ${JSON.stringify(repo)} --by claude --stop`);
|
|
469
639
|
// attached already? "already attached" only when every hook of THIS version is present; an older
|
|
470
640
|
// attachment (say, without the SessionStart[compact] hook) is upgraded in place, never left stale.
|
|
471
641
|
let upgrading = false;
|
|
472
|
-
if (JSON.stringify(cfg)
|
|
642
|
+
if (OURS.test(JSON.stringify(cfg))) {
|
|
473
643
|
const has = (event, command, matcher) => ((cfg.hooks || {})[event] || []).some((h) => h && (matcher ? h.matcher === matcher : !h.matcher) && Array.isArray(h.hooks) && h.hooks.some((x) => x && x.command === command));
|
|
474
|
-
if (has('UserPromptSubmit', ctx) && has('SessionStart', ctx, 'compact') && has('Stop', chk)) { out(`sawbuck: already attached (${user ? 'user' : 'claude'}) in ${f}`); return; }
|
|
644
|
+
if (has('UserPromptSubmit', ctx) && has('SessionStart', ctx, 'compact') && has('Stop', chk)) { out(`sawbuck: already attached (${user ? 'user' : shared ? 'shared' : 'claude'}) in ${f}`); return; }
|
|
475
645
|
upgrading = true;
|
|
476
646
|
}
|
|
647
|
+
const mode = user ? 'user' : shared ? 'claude-shared' : 'claude';
|
|
648
|
+
const mp = user ? userManifestPath() : manifestPath(repo);
|
|
649
|
+
const stPath = join(repo, '.sawbuck', 'state.json');
|
|
650
|
+
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
651
|
+
// attach often runs inside a turn (saying yes to the offer), and that turn's Stop hook then has no baseline, so the
|
|
652
|
+
// files signing just wrote would read as the agent's violations. What is here now is the baseline; anything after it counts.
|
|
653
|
+
const baseline = !user && existsSync(join(repo, 'promptwheel.config.json')) && !st.turnBaseline;
|
|
477
654
|
if (dry) {
|
|
478
655
|
out(dryText([
|
|
479
656
|
`${upgrading ? 'update' : fileExisted ? 'modify' : 'create'} ${f} (UserPromptSubmit + SessionStart[compact] + Stop hooks: the per-turn block, and the check)`,
|
|
480
|
-
user
|
|
481
|
-
|
|
657
|
+
!user && ensureExcluded(repo, true) ? `exclude ${excludeFile(repo)} (${EXCLUDED.join(', ')})` : null,
|
|
658
|
+
`${existsSync(mp) ? 'modify' : 'create'} ${mp} (attach receipt)`,
|
|
659
|
+
baseline ? `write ${stPath} (the turn baseline, so this turn's own signing is not read as the agent's)` : null,
|
|
660
|
+
`write ${regPath()} (the list of attached repos, for detach --all)`,
|
|
482
661
|
].filter(Boolean)));
|
|
483
662
|
return;
|
|
484
663
|
}
|
|
485
664
|
cfg.hooks = cfg.hooks || {};
|
|
486
665
|
const put = (event, command, matcher) => {
|
|
487
|
-
cfg.hooks[event] = (cfg.hooks[event] || []).filter((h) => !JSON.stringify(h)
|
|
666
|
+
cfg.hooks[event] = (cfg.hooks[event] || []).filter((h) => !OURS.test(JSON.stringify(h)));
|
|
488
667
|
cfg.hooks[event].push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] });
|
|
489
668
|
};
|
|
490
669
|
put('UserPromptSubmit', ctx);
|
|
@@ -494,30 +673,24 @@ function wireClaude(repo, opts) {
|
|
|
494
673
|
put('Stop', chk);
|
|
495
674
|
mkdirSync(dir, { recursive: true });
|
|
496
675
|
writeFileSync(f, JSON.stringify(cfg, null, 2) + '\n');
|
|
497
|
-
|
|
498
|
-
// files signing just wrote would read as the agent's violations. What is here now is the baseline; anything after it counts.
|
|
499
|
-
if (!user && existsSync(join(repo, 'promptwheel.config.json'))) {
|
|
676
|
+
if (baseline) {
|
|
500
677
|
try {
|
|
501
678
|
ensureExcluded(repo);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
st.turnBaseline = { sha, files: protectedDirty(repo, { store: true }) };
|
|
507
|
-
st.lastPromptAt = new Date().toISOString();
|
|
508
|
-
mkdirSync(dirname(stPath), { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
509
|
-
}
|
|
679
|
+
let sha = ''; try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
|
|
680
|
+
st.turnBaseline = { sha, files: protectedDirty(repo, { store: true }), by: 'attach' };
|
|
681
|
+
st.lastPromptAt = new Date().toISOString();
|
|
682
|
+
mkdirSync(dirname(stPath), { recursive: true }); writeFileSync(stPath, JSON.stringify(st, null, 2) + '\n');
|
|
510
683
|
} catch {}
|
|
511
684
|
}
|
|
512
|
-
const mode = user ? 'user' : 'claude';
|
|
513
|
-
const mp = user ? userManifestPath() : manifestPath(repo);
|
|
514
685
|
const m = readManifest(mp);
|
|
515
686
|
const prev = (m.attachments || {})[mode]; // an upgrade keeps the receipt of what the first attach created
|
|
516
687
|
m.attachments = { ...m.attachments, [mode]: { file: f, fileCreated: prev ? !!prev.fileCreated : !fileExisted, dirCreated: prev ? !!prev.dirCreated : !dirExisted } };
|
|
517
688
|
if (!user) syncExclude(repo, m);
|
|
518
689
|
writeManifest(mp, m);
|
|
519
|
-
|
|
520
|
-
|
|
690
|
+
regAdd(repo, mode);
|
|
691
|
+
const kind = user ? 'USER-LEVEL' : shared ? 'shared project' : 'project';
|
|
692
|
+
out(upgrading ? `sawbuck: updated the ${kind} Claude Code hooks in ${f} to this version` : `sawbuck: wired ${kind} Claude Code hooks in ${f}`);
|
|
693
|
+
if (shared) out(` commit ${relative(repo, f)} so the team gets the check. It runs sawbuck ${version} through npx on every machine.`);
|
|
521
694
|
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.');
|
|
522
695
|
if (user) out(' profile-switches by cwd: memory in any git repo, the check where a promptwheel.config.json exists.');
|
|
523
696
|
}
|
|
@@ -531,13 +704,13 @@ function unwireClaude(repo, { user }) {
|
|
|
531
704
|
const f = rec?.file || claudeFile(repo, { user, shared: mode === 'claude-shared' }).f;
|
|
532
705
|
if (existsSync(f)) {
|
|
533
706
|
let raw = ''; try { raw = readFileSync(f, 'utf8'); } catch {}
|
|
534
|
-
if (
|
|
707
|
+
if (OURS.test(raw)) {
|
|
535
708
|
let cfg = null; try { cfg = JSON.parse(raw); } catch {}
|
|
536
709
|
if (cfg) {
|
|
537
710
|
removeClaudeEntries(cfg);
|
|
538
|
-
if ((rec?.fileCreated || !rec) && isTrivialSettings(cfg)) {
|
|
539
|
-
else
|
|
540
|
-
|
|
711
|
+
if ((rec?.fileCreated || !rec) && isTrivialSettings(cfg)) { dropFile(f, 'our Claude Code hooks were all it held'); if (rec?.dirCreated) rmIfEmptyDir(dirname(f)); }
|
|
712
|
+
else putFile(f, JSON.stringify(cfg, null, 2) + '\n', 'our Claude Code hooks');
|
|
713
|
+
say(`sawbuck: removed Claude Code hooks from ${f}`);
|
|
541
714
|
did = true;
|
|
542
715
|
}
|
|
543
716
|
}
|
|
@@ -545,77 +718,120 @@ function unwireClaude(repo, { user }) {
|
|
|
545
718
|
if (rec) { delete m.attachments[mode]; touched = true; }
|
|
546
719
|
}
|
|
547
720
|
if (touched) { if (!user) syncExclude(repo, m); writeManifest(mp, m); }
|
|
548
|
-
|
|
721
|
+
for (const mode of modes) regRemove(repo, mode);
|
|
722
|
+
if (!did && !touched) say('sawbuck: no Claude attachment found to detach');
|
|
549
723
|
}
|
|
550
724
|
function removeClaudeEntries(cfg) {
|
|
551
725
|
if (!cfg.hooks) return;
|
|
552
726
|
for (const ev of ['UserPromptSubmit', 'SessionStart', 'Stop']) {
|
|
553
727
|
if (!Array.isArray(cfg.hooks[ev])) continue;
|
|
554
|
-
cfg.hooks[ev] = cfg.hooks[ev].filter((h) => !JSON.stringify(h)
|
|
728
|
+
cfg.hooks[ev] = cfg.hooks[ev].filter((h) => !OURS.test(JSON.stringify(h)));
|
|
555
729
|
if (!cfg.hooks[ev].length) delete cfg.hooks[ev]; // no empty [] residue
|
|
556
730
|
}
|
|
557
731
|
if (cfg.hooks && !Object.keys(cfg.hooks).length) delete cfg.hooks;
|
|
558
732
|
}
|
|
559
733
|
function isTrivialSettings(cfg) { return Object.keys(cfg).length === 0; }
|
|
560
734
|
|
|
561
|
-
// ---
|
|
735
|
+
// --- verify / doctor / purge: prove and clean the footprint ----------------
|
|
562
736
|
function footprint(repo) {
|
|
563
737
|
const items = [];
|
|
564
738
|
const pc = gitPath(repo, 'hooks/post-commit');
|
|
565
739
|
try { if (existsSync(pc) && readFileSync(pc, 'utf8').includes(S_START)) items.push({ what: pc, state: 'git hook' }); } catch {}
|
|
566
740
|
for (const f of [join(repo, '.claude', 'settings.local.json'), join(repo, '.claude', 'settings.json'), join(homedir(), '.claude', 'settings.json')]) {
|
|
567
|
-
try { if (existsSync(f) && readFileSync(f, 'utf8')
|
|
741
|
+
try { if (existsSync(f) && OURS.test(readFileSync(f, 'utf8'))) items.push({ what: f, state: 'claude hooks' }); } catch {}
|
|
568
742
|
}
|
|
569
743
|
const xf = excludeFile(repo);
|
|
570
|
-
|
|
571
|
-
|
|
744
|
+
// the exclude lines are part of the record while it is on disk (syncExclude), wiring only once it is gone
|
|
745
|
+
try { if (existsSync(xf) && readFileSync(xf, 'utf8').includes(S_START) && !hiddenLeft(repo).length) items.push({ what: xf, state: 'local exclude' }); } catch {}
|
|
572
746
|
const mp = manifestPath(repo);
|
|
573
747
|
if (existsSync(mp)) { const m = readManifest(mp); items.push({ what: mp, state: `receipt: ${Object.keys(m.attachments || {}).join(', ') || 'empty'}` }); }
|
|
574
748
|
return items;
|
|
575
749
|
}
|
|
576
750
|
function verifyClean(repo) {
|
|
577
|
-
// the record
|
|
578
|
-
const left = footprint(repo)
|
|
579
|
-
if (!left.length) out('sawbuck: verified, the
|
|
751
|
+
// the record may stay (your data, unless --purge, and named above); the mechanism must be gone.
|
|
752
|
+
const left = footprint(repo);
|
|
753
|
+
if (!left.length) out('sawbuck: verified, the hooks and the receipt are gone.');
|
|
580
754
|
else { out('sawbuck: NOT clean, these remain:'); for (const it of left) out(` ${it.state.padEnd(22)} ${it.what}`); process.exitCode = 1; }
|
|
581
755
|
}
|
|
582
756
|
function purgeRecord(repo) {
|
|
583
757
|
let n = 0;
|
|
758
|
+
const purge = (p) => { if (!there(p)) return; dropFile(p, 'the record'); n++; say(`sawbuck: purged ${p}`); };
|
|
584
759
|
for (const d of RECORD_DIRS) {
|
|
585
|
-
const p = join(repo, d);
|
|
586
760
|
// a committed LOGBOOK.md or JOURNEY.md is the person's file, not our record
|
|
587
761
|
try { if (execFileSync('git', ['-C', repo, 'ls-files', '--', d], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()) continue; } catch {}
|
|
588
|
-
|
|
762
|
+
purge(join(repo, d));
|
|
589
763
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (!n) out('sawbuck: no record to purge');
|
|
593
|
-
}
|
|
594
|
-
// is `sawbuck` resolvable on PATH? (SAWBUCK_NO_PATH_CHECK forces the strict/offline answer)
|
|
595
|
-
function onPath() {
|
|
596
|
-
if (process.env.SAWBUCK_NO_PATH_CHECK) return false;
|
|
597
|
-
try { execFileSync('sh', ['-c', 'command -v sawbuck'], { stdio: 'ignore' }); return true; } catch { return false; }
|
|
764
|
+
purge(join(repo, '.promptwheel', 'outcomes.jsonl')); // the rest of .promptwheel/ is the signed contract: yours to keep or delete
|
|
765
|
+
if (!n) say('sawbuck: no record to purge');
|
|
598
766
|
}
|
|
599
767
|
// the path a hook bakes in: the quoted one ending in sawbuck.mjs, in a shell hook or in JSON, where the quotes are escaped
|
|
600
768
|
function extractBaked(text) { const m = text.match(/"([^"]*sawbuck\.mjs)\\?"/); return m ? m[1] : null; }
|
|
769
|
+
// Does the hook still work? Run exactly what it runs, with --version in place of the hook's arguments: the same
|
|
770
|
+
// node lookup, the same baked path, the same npx fallback. A hook never looks sawbuck up on PATH, so neither does this.
|
|
771
|
+
function hookRuns(command) {
|
|
772
|
+
const probe = command.replace(/(sawbuck\.mjs"?|sawbuck@[\w.-]+) [^;]*/g, '$1 --version');
|
|
773
|
+
if (process.platform === 'win32') { const b = extractBaked(JSON.stringify(command)); return !!b && existsSync(b); }
|
|
774
|
+
try { return /^\d+\.\d+\.\d+/.test(execFileSync('sh', ['-c', probe], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 60000 }).trim()); } catch { return false; }
|
|
775
|
+
}
|
|
601
776
|
function doctorRepo(repo) {
|
|
602
777
|
const checks = [];
|
|
778
|
+
// the git hook tries a sawbuck on PATH first, then the baked path, then npx
|
|
603
779
|
const pc = gitPath(repo, 'hooks/post-commit');
|
|
604
|
-
try { if (existsSync(pc)) { const b = readFileSync(pc, 'utf8'); if (b.includes(S_START)) checks.push({ mode: 'git', file: pc,
|
|
605
|
-
for (const [mode, f] of [['claude', join(repo, '.claude', 'settings.local.json')], ['claude-shared', join(repo, '.claude', 'settings.json')]]) {
|
|
606
|
-
|
|
780
|
+
try { if (existsSync(pc)) { const b = readFileSync(pc, 'utf8'); if (b.includes(S_START)) { const baked = extractBaked(b); let onPath = false; try { onPath = !!execFileSync('sh', ['-c', 'command -v sawbuck'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {} checks.push({ mode: 'git', file: pc, ok: onPath || (!!baked && existsSync(baked)) || /npx -y sawbuck@/.test(b) }); } } } catch {}
|
|
781
|
+
for (const [mode, f] of [['claude', join(repo, '.claude', 'settings.local.json')], ['claude-shared', join(repo, '.claude', 'settings.json')], ['user', join(homedir(), '.claude', 'settings.json')]]) {
|
|
782
|
+
let cfg = null; try { cfg = JSON.parse(readFileSync(f, 'utf8')); } catch { continue; }
|
|
783
|
+
const ours = (list) => (Array.isArray(list) ? list : []).flatMap((h) => (h && Array.isArray(h.hooks) ? h.hooks : [])).map((x) => x && x.command).filter((c) => typeof c === 'string' && OURS.test(c));
|
|
784
|
+
const cmds = ours(Object.values((cfg && cfg.hooks) || {}).flat());
|
|
785
|
+
if (!cmds.length) continue;
|
|
786
|
+
// the set attach writes, each under its own event: the rule on a prompt and after a compaction, the check on Stop
|
|
787
|
+
const at = (event, re, matcher) => ours(((cfg.hooks || {})[event] || []).filter((h) => !matcher || (h && h.matcher === matcher))).some((c) => re.test(c));
|
|
788
|
+
const missing = [at('UserPromptSubmit', /\bturn --hook\b/) ? '' : 'UserPromptSubmit', at('SessionStart', /\bturn --hook\b/, 'compact') ? '' : 'SessionStart[compact]', at('Stop', /__hook\b.*--stop\b/) ? '' : 'Stop with --stop'].filter(Boolean);
|
|
789
|
+
checks.push({ mode, file: f, ok: !missing.length && [...new Set(cmds)].every(hookRuns), missing });
|
|
607
790
|
}
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
|
|
791
|
+
// the receipt says hooks were wired here, and they are gone: a removal git status cannot show (the file is excluded, or under .git)
|
|
792
|
+
const receipt = Object.keys(readManifest(manifestPath(repo)).attachments || {}).filter((m) => !checks.some((c) => c.mode === m));
|
|
793
|
+
if (!checks.length && !receipt.length) { out(`sawbuck: no attachment in ${repo}, nothing to check.`); return; }
|
|
794
|
+
let bad = receipt.length;
|
|
795
|
+
const again = (m) => `sawbuck attach${{ git: ' --git', 'claude-shared': ' --shared', user: ' --user' }[m] || ''} again`;
|
|
611
796
|
out(`sawbuck doctor, ${repo}:`);
|
|
612
797
|
for (const c of checks) {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
out(` ${ok ? 'OK ' : 'STALE'} ${c.mode.padEnd(14)} ${c.file}${
|
|
798
|
+
if (!c.ok) bad++;
|
|
799
|
+
const why = c.ok ? '' : c.missing && c.missing.length ? ` (the hook set is incomplete, missing: ${c.missing.join(', ')}; run ${again(c.mode)})` : ` (the hook does not run: what it calls is gone; run ${again(c.mode)})`;
|
|
800
|
+
out(` ${c.ok ? 'OK ' : c.missing && c.missing.length ? 'PART ' : 'STALE'} ${c.mode.padEnd(14)} ${c.file}${why}`);
|
|
616
801
|
}
|
|
617
|
-
out(
|
|
802
|
+
for (const m of receipt) out(` GONE ${m.padEnd(14)} the receipt says ${m === 'git' ? 'a post-commit hook was wired here, but our block is gone' : 'hooks were wired here, but the hooks are gone'}. Run ${again(m)}, or sawbuck detach to drop the receipt.`);
|
|
618
803
|
if (bad) process.exitCode = 1;
|
|
619
804
|
}
|
|
805
|
+
function doctorAll() {
|
|
806
|
+
const r = regRead();
|
|
807
|
+
if (!r.repos.length) { out('sawbuck: not attached anywhere.'); return; }
|
|
808
|
+
out('sawbuck is attached to:');
|
|
809
|
+
for (const x of r.repos) out(` ${x.mode.padEnd(14)} ${x.repo}`);
|
|
810
|
+
for (const repo of new Set(r.repos.map((x) => x.repo))) doctorRepo(repo);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// --- every attachment, in one list, so there is one way out of all of them ---
|
|
814
|
+
function regPath() { return join(homedir(), '.sawbuck', 'registry.json'); }
|
|
815
|
+
function regRead() { if (planned.registry) return { ...planned.registry }; 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
|
|
816
|
+
function regWrite(r) {
|
|
817
|
+
const f = regPath();
|
|
818
|
+
if (dry) planned.registry = r;
|
|
819
|
+
if (!r.repos.length) { dropFile(f, 'the list of attached repos'); rmIfEmptyDir(dirname(f)); return; }
|
|
820
|
+
putFile(f, JSON.stringify(r, null, 2) + '\n', 'the list of attached repos');
|
|
821
|
+
}
|
|
822
|
+
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); }
|
|
823
|
+
function regRemove(repo, mode) { const r = regRead(); const keep = r.repos.filter((x) => !(x.mode === mode && (mode === 'user' || x.repo === repo))); if (keep.length !== r.repos.length) regWrite({ ...r, repos: keep }); }
|
|
824
|
+
function detachAll(purge) {
|
|
825
|
+
const r = regRead();
|
|
826
|
+
if (!r.repos.length) { say('sawbuck: nothing to detach: not attached anywhere.'); return; }
|
|
827
|
+
for (const x of [...r.repos]) {
|
|
828
|
+
if (x.mode === 'git') unwireGit(x.repo);
|
|
829
|
+
else if (x.mode === 'user') unwireClaude(x.repo, { user: true });
|
|
830
|
+
else unwireClaude(x.repo, {});
|
|
831
|
+
if (purge) purgeRecord(x.repo);
|
|
832
|
+
syncExclude(x.repo, readManifest(manifestPath(x.repo)));
|
|
833
|
+
}
|
|
834
|
+
say('sawbuck: detached everywhere.');
|
|
835
|
+
}
|
|
620
836
|
// --- shared helpers ---------------------------------------------------------
|
|
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'); }
|
|
837
|
+
function dryText(plan, verb = 'leave') { return verb === 'remove' ? ['sawbuck --dry-run: would remove (changing nothing):', ...(plan.length ? plan.map((p) => ' ' + p) : [' nothing: no wiring here'])].join('\n') : ['sawbuck --dry-run: would leave this footprint (writing nothing):', ...plan.map((p) => ' ' + p), ' all of it reversible with `detach`.'].join('\n'); }
|