sawbuck 0.1.36 โ 0.1.38
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-cmd.mjs +2 -0
- package/exclude.mjs +24 -0
- package/package.json +2 -1
- package/sawbuck-hook.mjs +11 -18
- package/sawbuck.mjs +22 -10
- package/status.mjs +4 -3
package/contract-cmd.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// here, adds your tests and tripwires if there is none, then the supply-chain protections and the architecture
|
|
4
4
|
// lock. Zero-LLM. Re-running changes nothing. Protected product code (auth, billing) is suggested, never added:
|
|
5
5
|
// freezing those is a person's decision.
|
|
6
|
+
import { ensureExcluded } from './exclude.mjs';
|
|
6
7
|
import { execFileSync } from 'node:child_process';
|
|
7
8
|
import { readFileSync, writeFileSync, existsSync, mkdtempSync, rmSync, cpSync, mkdirSync } from 'node:fs';
|
|
8
9
|
import { join, dirname, resolve } from 'node:path';
|
|
@@ -48,6 +49,7 @@ try {
|
|
|
48
49
|
const next = text.replace(/^\.promptwheel\/?[ \t]*$/gm, '.promptwheel/*\n!.promptwheel/packs/');
|
|
49
50
|
if (next !== text) writeFileSync(f, next);
|
|
50
51
|
}
|
|
52
|
+
ensureExcluded(repo);
|
|
51
53
|
const locked = existsSync(join(repo, 'promptwheel.lock.json'));
|
|
52
54
|
if (!locked) run([join(pack, 'lock.mjs'), 'write', repo], repo);
|
|
53
55
|
const added = (cfg.mandates.length - before.m) + (cfg.metrics.length - before.g);
|
package/exclude.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Keep sawbuck's own record out of git status, before anything is written: signed files are the person's to
|
|
2
|
+
// commit, the record is not, and an untracked .sawbuck/ would trip the rule that protects it.
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { resolve, dirname } from 'node:path';
|
|
6
|
+
const sh = (r, ...a) => { try { return execFileSync('git', ['-C', r, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
7
|
+
|
|
8
|
+
export function ensureExcluded(r) {
|
|
9
|
+
try {
|
|
10
|
+
// the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
|
|
11
|
+
const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
|
|
12
|
+
if (!rel) return;
|
|
13
|
+
const f = resolve(r, rel);
|
|
14
|
+
// the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
|
|
15
|
+
// the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
|
|
16
|
+
const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/*\n!.promptwheel/packs/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n.claude/settings.local.json\n# <<< sawbuck <<<\n';
|
|
17
|
+
const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
|
|
18
|
+
const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
|
|
19
|
+
const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
|
|
20
|
+
if (next === body) return;
|
|
21
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
22
|
+
writeFileSync(f, next);
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.38",
|
|
4
4
|
"description": "Sign your agent to a contract: a deterministic, zero-LLM check re-proves each win from the agent's source edits alone, so a test that went green by editing the test, grader, or config is sent back. For Claude Code, with Codex support in beta.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"sawbuck.mjs",
|
|
41
41
|
"rewrites.mjs",
|
|
42
|
+
"exclude.mjs",
|
|
42
43
|
"contract-cmd.mjs",
|
|
43
44
|
"bins.mjs",
|
|
44
45
|
"contract.mjs",
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { ensureExcluded } from './exclude.mjs';
|
|
2
3
|
// sawbuck-hook.mjs: the Sawbuck Claude Code Stop-hook entry.
|
|
3
4
|
// Each turn: if a new commit landed, run reflect on it; then render the dispatch
|
|
4
5
|
// on a fresh catch OR every N turns (config). State in .sawbuck/state.json.
|
|
@@ -124,7 +125,16 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
124
125
|
const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
|
|
125
126
|
const accept = committedHit ? ' To accept this change on purpose, change the mandate in the contract or revert the path.' : '';
|
|
126
127
|
const upgrade = base ? '' : ' (No prompt baseline is recorded in this repo, so edits a person made before the prompt cannot be told apart from yours: ask them to run sawbuck attach --claude again, which upgrades the hooks.)';
|
|
127
|
-
|
|
128
|
+
// one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
|
|
129
|
+
// a block must not replace a gamed verdict that is still standing.
|
|
130
|
+
if (st.mandateCountedPrompt !== promptId) {
|
|
131
|
+
st.mandateCountedPrompt = promptId;
|
|
132
|
+
const r = readRec() || { regressions: 0, gamed: 0, latest: null };
|
|
133
|
+
r.mandates = (r.mandates || 0) + 1;
|
|
134
|
+
r.lastMandate = { name: hits[0].name, file: hits[0].files[0], at: now };
|
|
135
|
+
writeRec(r);
|
|
136
|
+
logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
|
|
137
|
+
}
|
|
128
138
|
if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
|
|
129
139
|
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
130
140
|
saveState();
|
|
@@ -193,20 +203,3 @@ saveState();
|
|
|
193
203
|
// dirties the worktree, including under `attach --user`, which is global and cannot
|
|
194
204
|
// set this up per-repo at attach time. Idempotent; markers match the CLI's so a
|
|
195
205
|
// per-repo `detach` removes them.
|
|
196
|
-
function ensureExcluded(r) {
|
|
197
|
-
try {
|
|
198
|
-
// the repository's own exclude file, wherever its git dir is (a worktree or submodule keeps it elsewhere)
|
|
199
|
-
const rel = sh(r, 'rev-parse', '--git-path', 'info/exclude');
|
|
200
|
-
if (!rel) return;
|
|
201
|
-
const f = resolve(r, rel);
|
|
202
|
-
// the record dirs + the logbook's root ledger cache (events.jsonl). Rewrite the block to
|
|
203
|
-
// the CURRENT set rather than bail on presence, so a grown set retrofits existing repos.
|
|
204
|
-
const WANT = '# >>> sawbuck >>>\n.sawbuck/\n.promptwheel/*\n!.promptwheel/packs/\nevents.jsonl\nannotations.jsonl\nannotation-reviews.jsonl\ndecision-cards.jsonl\nLOGBOOK.md\nJOURNEY.md\n.claude/settings.local.json\n# <<< sawbuck <<<\n';
|
|
205
|
-
const body = existsSync(f) ? readFileSync(f, 'utf8') : '';
|
|
206
|
-
const stripped = body.replace(/# >>> sawbuck >>>[\s\S]*?# <<< sawbuck <<<\n?/g, '');
|
|
207
|
-
const next = (stripped && !stripped.endsWith('\n') ? stripped + '\n' : stripped) + WANT;
|
|
208
|
-
if (next === body) return;
|
|
209
|
-
mkdirSync(dirname(f), { recursive: true });
|
|
210
|
-
writeFileSync(f, next);
|
|
211
|
-
} catch {}
|
|
212
|
-
}
|
package/sawbuck.mjs
CHANGED
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
16
|
import { erasedBlock } from './rewrites.mjs';
|
|
17
|
+
import { ensureExcluded } from './exclude.mjs';
|
|
17
18
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
|
|
18
|
-
import { join, dirname, resolve } from 'node:path';
|
|
19
|
+
import { basename, join, dirname, resolve } from 'node:path';
|
|
19
20
|
import { fileURLToPath } from 'node:url';
|
|
20
21
|
import { homedir } from 'node:os';
|
|
21
22
|
import { recall as memRecall, conform as memConform } from './memory.mjs';
|
|
@@ -190,7 +191,7 @@ switch (cmd) {
|
|
|
190
191
|
if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
|
|
191
192
|
const ni = rest.indexOf('-n');
|
|
192
193
|
const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
|
|
193
|
-
const icon = { verified: 'โ', gamed: '๐ฉ', regression: 'โ', inconclusive: 'โ', allowed: 'โ' };
|
|
194
|
+
const icon = { verified: 'โ', gamed: '๐ฉ', regression: 'โ', inconclusive: 'โ', allowed: 'โ', mandate: 'โ' };
|
|
194
195
|
const recent = lines.slice(-n).reverse();
|
|
195
196
|
out(`sawbuck activity (last ${recent.length}):`);
|
|
196
197
|
for (const l of recent) {
|
|
@@ -244,6 +245,7 @@ switch (cmd) {
|
|
|
244
245
|
if (flags.has('--hook') && !process.stdin.isTTY) { try { hookEvent = String(JSON.parse(readFileSync(0, 'utf8') || '{}').hook_event_name || ''); } catch {} }
|
|
245
246
|
if (hookEvent === 'UserPromptSubmit' && top) {
|
|
246
247
|
try {
|
|
248
|
+
ensureExcluded(repo); // before the first write: under user-level hooks nothing else has done it yet
|
|
247
249
|
const stDir = join(repo, '.sawbuck'); const stPath = join(stDir, 'state.json');
|
|
248
250
|
let st = {}; try { st = JSON.parse(readFileSync(stPath, 'utf8')); } catch {}
|
|
249
251
|
const now = new Date().toISOString();
|
|
@@ -344,10 +346,12 @@ switch (cmd) {
|
|
|
344
346
|
case 'statusline': {
|
|
345
347
|
// compact status-line segment for the cwd repo. Never throws; fast.
|
|
346
348
|
try {
|
|
347
|
-
const repo = process.cwd();
|
|
348
|
-
if (
|
|
349
|
+
const repo = gitTop(process.cwd());
|
|
350
|
+
if (!repo) process.stdout.write('๐ด sawbuck ยท no repo here');
|
|
351
|
+
if (repo) {
|
|
352
|
+
const name = basename(repo);
|
|
349
353
|
let caught = 0;
|
|
350
|
-
try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0); } catch {}
|
|
354
|
+
try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
|
|
351
355
|
// the last verdict from the activity feed (includes verified passes), so you see it working each turn
|
|
352
356
|
let last = '';
|
|
353
357
|
try { const L = readFileSync(join(repo, '.sawbuck', 'activity.jsonl'), 'utf8').trim().split('\n'); const e = JSON.parse(L[L.length - 1]); last = { verified: 'โ', gamed: '๐ฉ', regression: 'โ', inconclusive: 'โ', allowed: 'โ' }[e.kind] || ''; } catch {}
|
|
@@ -355,20 +359,22 @@ switch (cmd) {
|
|
|
355
359
|
const active = existsSync(join(repo, '.sawbuck', 'state.json'));
|
|
356
360
|
const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
|
|
357
361
|
const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
|
|
358
|
-
const
|
|
359
|
-
const
|
|
362
|
+
const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
|
|
363
|
+
const seg = label ? `๐ด sawbuck ยท ${label}` : '';
|
|
364
|
+
const named = label ? `๐ด sawbuck ยท ${name} ยท ${label}` : ''; // which repo it detected, in the default line
|
|
365
|
+
const base = named ? (last ? `${named} ยท ${last}` : named) : '';
|
|
360
366
|
// live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
|
|
361
367
|
let st = '';
|
|
362
368
|
try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) { const p = Math.round(((sj.ema != null ? sj.ema : sj.steeringRate) || 0) * 100); st = `${p >= 50 ? 'โ' : '๐'} steer ${p}%`; } } catch {}
|
|
363
369
|
// configurable template: SAWBUCK_STATUSLINE picks which segments show and in what order
|
|
364
|
-
// (tokens {status} {sawbuck} {verdict} {steer} plus any literal text); empty tokens collapse
|
|
370
|
+
// (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
|
|
365
371
|
// so you never get a dangling separator. Unset = the default composition. Mix in other
|
|
366
372
|
// tools (git branch, model, your own adapters) by composing in your statusline script.
|
|
367
373
|
const tpl = process.env.SAWBUCK_STATUSLINE;
|
|
368
374
|
let outp;
|
|
369
375
|
if (tpl && tpl.trim()) {
|
|
370
|
-
const tok = { status: base, sawbuck: seg, verdict: last, steer: st };
|
|
371
|
-
outp = tpl.replace(/\{(status|sawbuck|verdict|steer)\}/g, (_, k) => tok[k] || '')
|
|
376
|
+
const tok = { status: base, sawbuck: seg, repo: label ? name : '', verdict: last, steer: st };
|
|
377
|
+
outp = tpl.replace(/\{(status|sawbuck|repo|verdict|steer)\}/g, (_, k) => tok[k] || '')
|
|
372
378
|
.replace(/\s*ยท\s*ยท\s*/g, ' ยท ').replace(/^\s*ยท\s*/, '').replace(/\s*ยท\s*$/, '').replace(/\s{2,}/g, ' ').trim();
|
|
373
379
|
} else {
|
|
374
380
|
outp = [base, st].filter(Boolean).join(' ยท ');
|
|
@@ -522,6 +528,12 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
|
|
|
522
528
|
}
|
|
523
529
|
function wireClaude(repo, opts) {
|
|
524
530
|
const { user, shared } = opts;
|
|
531
|
+
// user-level hooks already cover every repo; a second set would run each check twice and spend the
|
|
532
|
+
// send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
|
|
533
|
+
if (!user && !shared) {
|
|
534
|
+
const uf = claudeFile(repo, { user: true }).f;
|
|
535
|
+
try { if (readFileSync(uf, 'utf8').includes('sawbuck.mjs')) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add`); return; } } catch {}
|
|
536
|
+
}
|
|
525
537
|
const { dir, f } = claudeFile(repo, opts);
|
|
526
538
|
const dirExisted = existsSync(dir);
|
|
527
539
|
const fileExisted = existsSync(f);
|
package/status.mjs
CHANGED
|
@@ -22,17 +22,17 @@ const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } ca
|
|
|
22
22
|
const readLines = (p) => { try { return readFileSync(p, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
|
|
23
23
|
const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
24
24
|
|
|
25
|
-
const ICON = { verified: 'โ', gamed: '๐ฉ', regression: 'โ', inconclusive: 'โ', allowed: 'โ' };
|
|
25
|
+
const ICON = { verified: 'โ', gamed: '๐ฉ', regression: 'โ', inconclusive: 'โ', allowed: 'โ', mandate: 'โ' };
|
|
26
26
|
|
|
27
27
|
// --- 1) the outcome record: what the check has done here -------------------
|
|
28
28
|
// results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
|
|
29
29
|
// the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
|
|
30
30
|
const results = readJSON(join(repo, '.sawbuck', 'results.json')) || {};
|
|
31
31
|
const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
32
|
-
const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0 };
|
|
32
|
+
const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0, mandate: 0 };
|
|
33
33
|
for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
|
|
34
34
|
const seen = feed.length;
|
|
35
|
-
const catchesAllTime = (results.regressions || 0) + (results.gamed || 0);
|
|
35
|
+
const catchesAllTime = (results.regressions || 0) + (results.gamed || 0) + (results.mandates || 0);
|
|
36
36
|
const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
|
|
37
37
|
const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
|
|
38
38
|
// the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file; the
|
|
@@ -89,6 +89,7 @@ if (seen || catchesAllTime) {
|
|
|
89
89
|
const parts = [
|
|
90
90
|
tally.verified ? `${ICON.verified} ${tally.verified} verified` : '',
|
|
91
91
|
tally.gamed ? `${ICON.gamed} ${tally.gamed} gamed` : '',
|
|
92
|
+
tally.mandate ? `${ICON.mandate} ${tally.mandate} protected path${tally.mandate === 1 ? '' : 's'}` : '',
|
|
92
93
|
tally.regression ? `${ICON.regression} ${tally.regression} regression${tally.regression === 1 ? '' : 's'}` : '',
|
|
93
94
|
tally.inconclusive ? `${ICON.inconclusive} ${tally.inconclusive} inconclusive` : '',
|
|
94
95
|
tally.allowed ? `${ICON.allowed} ${tally.allowed} allowed` : '',
|