sawbuck 0.1.36 โ 0.1.37
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/package.json +1 -1
- package/sawbuck-hook.mjs +10 -1
- package/sawbuck.mjs +20 -10
- package/status.mjs +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
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",
|
package/sawbuck-hook.mjs
CHANGED
|
@@ -124,7 +124,16 @@ if (stop && !process.env.SAWBUCK_NO_ENFORCE) {
|
|
|
124
124
|
const committedHit = hits.some((h) => h.files.some((f) => committedSince.includes(f)));
|
|
125
125
|
const accept = committedHit ? ' To accept this change on purpose, change the mandate in the contract or revert the path.' : '';
|
|
126
126
|
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
|
-
|
|
127
|
+
// one catch per prompt, however many times the turn is sent back. Kept beside rec.latest, never in it:
|
|
128
|
+
// a block must not replace a gamed verdict that is still standing.
|
|
129
|
+
if (st.mandateCountedPrompt !== promptId) {
|
|
130
|
+
st.mandateCountedPrompt = promptId;
|
|
131
|
+
const r = readRec() || { regressions: 0, gamed: 0, latest: null };
|
|
132
|
+
r.mandates = (r.mandates || 0) + 1;
|
|
133
|
+
r.lastMandate = { name: hits[0].name, file: hits[0].files[0], at: now };
|
|
134
|
+
writeRec(r);
|
|
135
|
+
logActivity({ kind: 'mandate', file: hits[0].files[0], reason: `mandate ${hits[0].name} violated in the working tree`, by: by || 'hook' });
|
|
136
|
+
}
|
|
128
137
|
if ((st.mandateBlocks || 0) < MAX_BLOCKS) {
|
|
129
138
|
st.mandateBlocks = (st.mandateBlocks || 0) + 1;
|
|
130
139
|
saveState();
|
package/sawbuck.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { execFileSync } from 'node:child_process';
|
|
16
16
|
import { erasedBlock } from './rewrites.mjs';
|
|
17
17
|
import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, unlinkSync, rmdirSync, rmSync, readdirSync } from 'node:fs';
|
|
18
|
-
import { join, dirname, resolve } from 'node:path';
|
|
18
|
+
import { basename, join, dirname, resolve } from 'node:path';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { recall as memRecall, conform as memConform } from './memory.mjs';
|
|
@@ -190,7 +190,7 @@ switch (cmd) {
|
|
|
190
190
|
if (!lines.length) { out('sawbuck: no activity recorded yet (the check logs here on each commit/turn).'); break; }
|
|
191
191
|
const ni = rest.indexOf('-n');
|
|
192
192
|
const n = ni > -1 ? (parseInt(rest[ni + 1], 10) || 20) : 20;
|
|
193
|
-
const icon = { verified: 'โ', gamed: '๐ฉ', regression: 'โ', inconclusive: 'โ', allowed: 'โ' };
|
|
193
|
+
const icon = { verified: 'โ', gamed: '๐ฉ', regression: 'โ', inconclusive: 'โ', allowed: 'โ', mandate: 'โ' };
|
|
194
194
|
const recent = lines.slice(-n).reverse();
|
|
195
195
|
out(`sawbuck activity (last ${recent.length}):`);
|
|
196
196
|
for (const l of recent) {
|
|
@@ -344,10 +344,12 @@ switch (cmd) {
|
|
|
344
344
|
case 'statusline': {
|
|
345
345
|
// compact status-line segment for the cwd repo. Never throws; fast.
|
|
346
346
|
try {
|
|
347
|
-
const repo = process.cwd();
|
|
348
|
-
if (
|
|
347
|
+
const repo = gitTop(process.cwd());
|
|
348
|
+
if (!repo) process.stdout.write('๐ด sawbuck ยท no repo here');
|
|
349
|
+
if (repo) {
|
|
350
|
+
const name = basename(repo);
|
|
349
351
|
let caught = 0;
|
|
350
|
-
try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0); } catch {}
|
|
352
|
+
try { const r = JSON.parse(readFileSync(join(repo, '.sawbuck', 'results.json'), 'utf8')); caught = (r.regressions || 0) + (r.gamed || 0) + (r.mandates || 0); } catch {}
|
|
351
353
|
// the last verdict from the activity feed (includes verified passes), so you see it working each turn
|
|
352
354
|
let last = '';
|
|
353
355
|
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 +357,22 @@ switch (cmd) {
|
|
|
355
357
|
const active = existsSync(join(repo, '.sawbuck', 'state.json'));
|
|
356
358
|
const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
|
|
357
359
|
const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
|
|
358
|
-
const
|
|
359
|
-
const
|
|
360
|
+
const label = caught > 0 ? `${caught} caught` : active ? 'watching' : armed ? 'ready' : mem ? 'memory' : '';
|
|
361
|
+
const seg = label ? `๐ด sawbuck ยท ${label}` : '';
|
|
362
|
+
const named = label ? `๐ด sawbuck ยท ${name} ยท ${label}` : ''; // which repo it detected, in the default line
|
|
363
|
+
const base = named ? (last ? `${named} ยท ${last}` : named) : '';
|
|
360
364
|
// live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
|
|
361
365
|
let st = '';
|
|
362
366
|
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
367
|
// 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
|
|
368
|
+
// (tokens {status} {sawbuck} {repo} {verdict} {steer} plus any literal text); empty tokens collapse
|
|
365
369
|
// so you never get a dangling separator. Unset = the default composition. Mix in other
|
|
366
370
|
// tools (git branch, model, your own adapters) by composing in your statusline script.
|
|
367
371
|
const tpl = process.env.SAWBUCK_STATUSLINE;
|
|
368
372
|
let outp;
|
|
369
373
|
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] || '')
|
|
374
|
+
const tok = { status: base, sawbuck: seg, repo: label ? name : '', verdict: last, steer: st };
|
|
375
|
+
outp = tpl.replace(/\{(status|sawbuck|repo|verdict|steer)\}/g, (_, k) => tok[k] || '')
|
|
372
376
|
.replace(/\s*ยท\s*ยท\s*/g, ' ยท ').replace(/^\s*ยท\s*/, '').replace(/\s*ยท\s*$/, '').replace(/\s{2,}/g, ' ').trim();
|
|
373
377
|
} else {
|
|
374
378
|
outp = [base, st].filter(Boolean).join(' ยท ');
|
|
@@ -522,6 +526,12 @@ export function nodeCmd(execPath = process.execPath, platform = process.platform
|
|
|
522
526
|
}
|
|
523
527
|
function wireClaude(repo, opts) {
|
|
524
528
|
const { user, shared } = opts;
|
|
529
|
+
// user-level hooks already cover every repo; a second set would run each check twice and spend the
|
|
530
|
+
// send-back budget twice as fast. --shared is a deliberate team file, so it is written anyway.
|
|
531
|
+
if (!user && !shared) {
|
|
532
|
+
const uf = claudeFile(repo, { user: true }).f;
|
|
533
|
+
try { if (readFileSync(uf, 'utf8').includes('sawbuck.mjs')) { out(`sawbuck: already watching here through your user-level hooks (${uf}); nothing to add`); return; } } catch {}
|
|
534
|
+
}
|
|
525
535
|
const { dir, f } = claudeFile(repo, opts);
|
|
526
536
|
const dirExisted = existsSync(dir);
|
|
527
537
|
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` : '',
|