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