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/shape.mjs
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
// Your steering shape: what you push on, as 11 spokes in 4 groups. The model labels a sample of your messages; only the
|
|
2
2
|
// totals are kept. Three models agreed on single messages about 70% of the time and on the shape at 0.87 or better, so a
|
|
3
3
|
// single label is never shown. It says what you pushed on, not how good you are. Each run is saved with its date.
|
|
4
|
-
|
|
4
|
+
//
|
|
5
|
+
// What is not said: the model also reads a sample of real turns and names habits the agent has that you never push on.
|
|
6
|
+
// Nothing is counted. An earlier version counted with patterns, and reading the turns showed the counts were wrong.
|
|
7
|
+
// Each habit must quote the agent from 3 turns, a plain check confirms the quotes, and the excerpts are deleted after.
|
|
8
|
+
// Tested on one person: on 120 turns two models named the same two habits and differed on the third, so the output
|
|
9
|
+
// calls them leads.
|
|
10
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmSync, copyFileSync, chmodSync } from 'node:fs';
|
|
5
11
|
import { execFileSync } from 'node:child_process';
|
|
6
12
|
import { join, dirname } from 'node:path';
|
|
7
13
|
import { homedir } from 'node:os';
|
|
8
|
-
import { prepareRules } from './rules.mjs';
|
|
14
|
+
import { prepareRules, SECRET, forget } from './rules.mjs';
|
|
15
|
+
import { readHistory, typedText } from './history.mjs';
|
|
9
16
|
|
|
10
17
|
export const GROUPS = { Truth: ['correctness', 'proof', 'honesty'], Intent: ['scope', 'autonomy'], Craft: ['voice', 'naming', 'visual', 'simplicity'], Control: ['process', 'safety'] };
|
|
11
18
|
const SPOKES = Object.values(GROUPS).flat();
|
|
12
19
|
const MIN_SPOKES = 200; // below this, spokes are noise: show the 4 groups
|
|
13
20
|
const SAMPLE = 600;
|
|
14
|
-
const
|
|
21
|
+
const TURNS = 120, MIN_TURNS = 20, MIN_CITES = 3; // turns read (at 60, two reads named different habits; at 120 two models agreed on 2 of 3), fewest worth reading, turns a habit must be quoted from
|
|
22
|
+
const paths = (home) => ({ dir: join(home, '.sawbuck'), rules: join(home, '.sawbuck', 'rule-input.txt'), input: join(home, '.sawbuck', 'shape-input.txt'), hist: join(home, '.sawbuck', 'shape-history.jsonl'), svg: join(home, '.sawbuck', 'shape.svg'), turns: join(home, '.sawbuck', 'shape-turns.txt'), notsaid: join(home, '.sawbuck', 'shape-notsaid.json') });
|
|
15
23
|
const title = (s) => s[0].toUpperCase() + s.slice(1);
|
|
16
24
|
|
|
17
25
|
export function prepareShape({ home = homedir() } = {}) {
|
|
@@ -20,8 +28,79 @@ export function prepareShape({ home = homedir() } = {}) {
|
|
|
20
28
|
const lines = readFileSync(p.rules, 'utf8').split('\n').filter(Boolean);
|
|
21
29
|
for (let i = lines.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [lines[i], lines[j]] = [lines[j], lines[i]]; }
|
|
22
30
|
const pick = lines.slice(0, SAMPLE);
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
const priv = (f, text) => { writeFileSync(f, text, { mode: 0o600 }); chmodSync(f, 0o600); }; // the mode alone only applies to a new file
|
|
32
|
+
priv(p.input, pick.map((l, i) => `${i + 1}\t${l}`).join('\n') + '\n');
|
|
33
|
+
const turns = sampleTurns({ home });
|
|
34
|
+
rmSync(p.notsaid, { force: true });
|
|
35
|
+
if (turns.length >= MIN_TURNS) priv(p.turns, turns.map((l, i) => `${i + 1}\t${l}`).join('\n') + '\n'); else rmSync(p.turns, { force: true });
|
|
36
|
+
return { input: p.input, lines: pick.length, secrets: r.secrets, turns: turns.length >= MIN_TURNS ? turns.length : 0, turnsFile: p.turns };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One line per past turn from the last 90 days: what you asked, what the agent did, how its reply ended, what you said
|
|
40
|
+
// next. Only sessions with 3 or more typed prompts: a one-prompt session is a script, not a conversation.
|
|
41
|
+
export function sampleTurns({ home = homedir(), n = TURNS, days = 90 } = {}) {
|
|
42
|
+
const since = Date.now() - days * 864e5, T = [];
|
|
43
|
+
const cut = (x, k) => String(x || '').replace(/\s+/g, ' ').trim().slice(0, k);
|
|
44
|
+
const noise = (t) => /^[<[]/.test(t) || t.startsWith('Caveat:') || t.startsWith('This session is being continued');
|
|
45
|
+
let cur = null, S = [], typed = 0;
|
|
46
|
+
const close = (next) => { if (cur && cur.tools) S.push({ ...cur, next }); cur = null; };
|
|
47
|
+
const end = () => { close(''); if (typed >= 3) T.push(...S); S = []; typed = 0; };
|
|
48
|
+
readHistory(join(home, '.claude', 'projects'), {
|
|
49
|
+
onSession: end,
|
|
50
|
+
onEvent: (e) => {
|
|
51
|
+
if (e.timestamp && Date.parse(e.timestamp) < since) return;
|
|
52
|
+
if (e.type === 'user') {
|
|
53
|
+
const t = typedText(e.message);
|
|
54
|
+
if (t === null || noise(t.trim())) return; // a tool result or a background notice is not your reply: the turn goes on
|
|
55
|
+
close(t); typed++; cur = { you: t, did: [], said: '', tools: 0 };
|
|
56
|
+
} else if (cur && Array.isArray(e.message && e.message.content)) {
|
|
57
|
+
for (const b of e.message.content) {
|
|
58
|
+
if (b.type === 'text' && b.text) cur.said = b.text;
|
|
59
|
+
if (b.type !== 'tool_use') continue;
|
|
60
|
+
cur.tools++;
|
|
61
|
+
const i = b.input || {};
|
|
62
|
+
if (cur.did.length < 8) cur.did.push(b.name === 'Bash' ? `ran: ${cut(i.command, 70)}` : /^(Edit|Write|MultiEdit)$/.test(b.name) ? `edited ${String(i.file_path || '').split('/').slice(-2).join('/')}` : b.name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
for (let i = T.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [T[i], T[j]] = [T[j], T[i]]; }
|
|
68
|
+
// the person's words are kept free of the line's separators, so nothing they typed can sit where the agent's side is read
|
|
69
|
+
const mine = (x, k) => cut(x, k).replace(/\|/g, '/');
|
|
70
|
+
return T.map((t) => `you: ${mine(t.you, 160)}${AGENT}(${t.tools} tool calls): ${t.did.join('; ')} | it ended: ${cut(t.said.slice(-220), 220)}${NEXT}${mine(t.next, 120) || '(session ended)'}`).filter((l) => !SECRET.test(l)).slice(0, n);
|
|
71
|
+
}
|
|
72
|
+
const AGENT = ' | it did ', NEXT = ' | you next: ';
|
|
73
|
+
|
|
74
|
+
// Keep a habit only if it quotes the agent's side of 3 different turns, word for word. Your own messages do not count
|
|
75
|
+
// as evidence and are never printed. Returns the lines to show, and removes the excerpts either way.
|
|
76
|
+
function notSaid(p) {
|
|
77
|
+
let turns = null, named = null;
|
|
78
|
+
try { turns = new Map(readFileSync(p.turns, 'utf8').split('\n').filter(Boolean).map((l) => { const i = l.indexOf('\t'); return [l.slice(0, i), l.slice(i + 1)]; })); } catch {}
|
|
79
|
+
try { const m = readFileSync(p.notsaid, 'utf8').match(/\[[\s\S]*\]/); named = m ? JSON.parse(m[0]) : null; } catch {}
|
|
80
|
+
rmSync(p.turns, { force: true }); rmSync(p.notsaid, { force: true });
|
|
81
|
+
if (!turns || !Array.isArray(named)) return [];
|
|
82
|
+
const norm = (x) => String(x || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
83
|
+
const one = (x, k) => (typeof x === 'string' ? x : '').replace(/\s+/g, ' ').trim().slice(0, k);
|
|
84
|
+
const agentSide = (l) => { const a = l.indexOf(AGENT), b = l.lastIndexOf(NEXT); return a > -1 && b > a ? norm(l.slice(a, b)) : ''; };
|
|
85
|
+
const out = [];
|
|
86
|
+
let kept = 0;
|
|
87
|
+
// the first 3 that hold up, wherever they sit in the answer; the answer is the model's, so nothing in it is trusted
|
|
88
|
+
for (const c of named.slice(0, 50)) {
|
|
89
|
+
if (kept === 3) break;
|
|
90
|
+
if (!c || typeof c !== 'object' || !one(c.what, 200)) continue;
|
|
91
|
+
const seen = new Set(), quotes = [];
|
|
92
|
+
for (const t of Array.isArray(c.turns) ? c.turns : []) {
|
|
93
|
+
if (!t || typeof t !== 'object' || typeof t.quote !== 'string') continue;
|
|
94
|
+
const q = norm(t.quote), id = String(t.n), l = turns.get(id);
|
|
95
|
+
if (typeof l !== 'string' || q.length < 12 || seen.has(id) || !agentSide(l).includes(q)) continue;
|
|
96
|
+
seen.add(id); quotes.push(one(t.quote, 80));
|
|
97
|
+
}
|
|
98
|
+
if (quotes.length < MIN_CITES) continue;
|
|
99
|
+
kept++;
|
|
100
|
+
out.push(` ${one(c.what, 200)} (${quotes.length} turns)`, ...[...new Set(quotes)].slice(0, 3).map((q) => ` "${q}"`), ...(one(c.rule, 200) ? [` Try: ${one(c.rule, 200)}`] : []));
|
|
101
|
+
}
|
|
102
|
+
const n = turns.size;
|
|
103
|
+
return out.length ? ['', ` Not said, from one read of ${n} past turns. Leads to check, with the agent's own words:`, ...out] : ['', ` Not said: nothing stood out in the ${n} past turns read.`];
|
|
25
104
|
}
|
|
26
105
|
|
|
27
106
|
export function tally(labels) {
|
|
@@ -38,7 +117,6 @@ export function tally(labels) {
|
|
|
38
117
|
return { n: total, none, groups, ratings, shares, spokes: total >= MIN_SPOKES };
|
|
39
118
|
}
|
|
40
119
|
|
|
41
|
-
// What was not said: each spoke we can measure from what the agent did, read against how often you push on it.
|
|
42
120
|
export function shapeSvg(t) {
|
|
43
121
|
const names = t.spokes ? SPOKES : Object.keys(GROUPS);
|
|
44
122
|
const vals = t.spokes ? names.map((s) => t.ratings[s] / 99) : names.map((g) => 0.25 + 0.75 * t.groups[g] / Math.max(1, ...Object.values(t.groups)));
|
|
@@ -68,18 +146,22 @@ function pngCard(svg) {
|
|
|
68
146
|
}
|
|
69
147
|
|
|
70
148
|
export function shapeCli(args, { home = homedir() } = {}) {
|
|
71
|
-
if (args.includes('--prepare')) { const r = prepareShape({ home }); return `๐ด ${r.lines} of your messages are ready in ${r.input}${r.secrets ? `, ${r.secrets} that looked like secrets left out` : ''}
|
|
149
|
+
if (args.includes('--prepare')) { const r = prepareShape({ home }); return `๐ด ${r.lines} of your messages are ready in ${r.input}${r.secrets ? `, ${r.secrets} that looked like secrets left out` : ''}.${r.turns ? `\n๐ด ${r.turns} past turns are ready in ${r.turnsFile}. They are deleted after the check.` : ''}`; }
|
|
72
150
|
const i = args.indexOf('--check');
|
|
73
151
|
if (i === -1) return 'sawbuck shape --prepare | --check <labels.json>';
|
|
74
152
|
const file = args[i + 1];
|
|
75
|
-
if (!file || !existsSync(file)) return '๐ด sawbuck: --check needs the labels file.';
|
|
76
|
-
const m = readFileSync(file, 'utf8').match(/\{[\s\S]*\}/);
|
|
77
|
-
const t = tally(m ? JSON.parse(m[0]) : {});
|
|
78
|
-
if (!t.n) return '๐ด sawbuck: no usable labels in that file.';
|
|
79
153
|
const p = paths(home);
|
|
154
|
+
// a check that cannot run still clears the excerpts: they are only for this one read
|
|
155
|
+
const stop = (msg) => { rmSync(p.turns, { force: true }); rmSync(p.notsaid, { force: true }); return msg; };
|
|
156
|
+
if (!file || !existsSync(file)) return stop('๐ด sawbuck: --check needs the labels file.');
|
|
157
|
+
let labels = {};
|
|
158
|
+
try { const m = readFileSync(file, 'utf8').match(/\{[\s\S]*\}/); labels = m ? JSON.parse(m[0]) : {}; } catch {}
|
|
159
|
+
const t = tally(labels && typeof labels === 'object' ? labels : {});
|
|
160
|
+
if (!t.n) return stop('๐ด sawbuck: no usable labels in that file.');
|
|
80
161
|
mkdirSync(p.dir, { recursive: true });
|
|
81
162
|
let prev = null;
|
|
82
|
-
|
|
163
|
+
try { const l = readFileSync(p.hist, 'utf8').trim().split('\n').filter(Boolean); if (l.length) prev = JSON.parse(l[l.length - 1]); } catch {}
|
|
164
|
+
if (!prev || typeof prev !== 'object' || !prev.groups) prev = null;
|
|
83
165
|
writeFileSync(p.svg, shapeSvg(t));
|
|
84
166
|
const out = [`๐ด your steering shape, from ${t.n} messages that pushed on something (${t.none} did not). What you push on, not a score.`, ''];
|
|
85
167
|
for (const [g, ss] of Object.entries(GROUPS)) {
|
|
@@ -89,6 +171,8 @@ export function shapeCli(args, { home = homedir() } = {}) {
|
|
|
89
171
|
}
|
|
90
172
|
if (!t.spokes) out.push('', ` Too few messages for the 11 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
|
|
91
173
|
appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares }) + '\n');
|
|
174
|
+
forget(home, p.input, p.rules, file); // the sampled messages and their labels were for this one read; the totals above are what stays
|
|
175
|
+
out.push(...notSaid(p));
|
|
92
176
|
out.push('', ` Card: ${p.svg}`);
|
|
93
177
|
if (args.includes('--png')) { const png = pngCard(p.svg); out.push(png ? ` Image to share: ${png}` : ' No image tool here (rsvg-convert or Chrome): open the card in a browser to save an image.'); }
|
|
94
178
|
return out.join('\n');
|
package/status.mjs
CHANGED
|
@@ -8,35 +8,34 @@
|
|
|
8
8
|
// Read-only, zero-dep, bounded. The debt scan is best-effort (a pointer if it cannot run in time).
|
|
9
9
|
// Usage: node status.mjs [--repo P] [--json]
|
|
10
10
|
|
|
11
|
+
import './pipe.mjs';
|
|
11
12
|
import { execFileSync } from 'node:child_process';
|
|
12
13
|
import { readFileSync, existsSync } from 'node:fs';
|
|
13
14
|
import { join, dirname, resolve, basename } from 'node:path';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { repoState } from './repo-state.mjs';
|
|
17
|
+
import { readSteers, groupSteers } from './steers.mjs';
|
|
16
18
|
|
|
17
19
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
20
|
const flag = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
|
|
19
21
|
const repo = resolve(flag('--repo', process.cwd()));
|
|
20
22
|
const asJson = process.argv.includes('--json');
|
|
21
23
|
|
|
22
|
-
const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
23
24
|
const readLines = (p) => { try { return readFileSync(p, 'utf8').split('\n').filter(Boolean); } catch { return []; } };
|
|
24
|
-
const git = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
|
|
25
25
|
|
|
26
26
|
const ICON = { verified: 'โ', gamed: '๐ฉ', regression: 'โ', inconclusive: 'โ', allowed: 'โ', mandate: 'โ' };
|
|
27
27
|
|
|
28
28
|
// --- 1) the outcome record: what the check has done here -------------------
|
|
29
29
|
// results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
|
|
30
30
|
// the recent per-check feed (verified included, capped ~200). We read both and keep each honest.
|
|
31
|
-
const { results, caught: catchesAllTime, state: kind, steer, armed, hasConfig } = repoState(repo);
|
|
31
|
+
const { results, caught: catchesAllTime, state: kind, steer, armed, hasConfig, wired } = repoState(repo);
|
|
32
|
+
const repeated = groupSteers(readSteers().steers).filter((g) => g.n >= 3 && g.sessions >= 2);
|
|
32
33
|
const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
33
34
|
const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0, mandate: 0 };
|
|
34
35
|
for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
|
|
35
36
|
const seen = feed.length;
|
|
36
37
|
const latest = results.latest || (feed.length ? feed[feed.length - 1] : null);
|
|
37
38
|
const since = feed.length ? (feed[0].at || '').slice(0, 10) : null;
|
|
38
|
-
// the steer rate, if the reins judge has been scoring turns here (an OFFLINE read of its file, in repoState; the
|
|
39
|
-
// recent-window average over roughly the last twenty scored turns, as the band and statusline show it)
|
|
40
39
|
|
|
41
40
|
// --- 2) the armed state: what it can measure here (shared with the statusline) ---
|
|
42
41
|
const isGit = !!existsSync(join(repo, '.git'));
|
|
@@ -64,6 +63,7 @@ if (asJson) {
|
|
|
64
63
|
config: hasConfig,
|
|
65
64
|
outcomes: { seen, caughtAllTime: catchesAllTime, gamedAllTime: results.gamed || 0, regressionsAllTime: results.regressions || 0, recent: tally, latest, since },
|
|
66
65
|
steer,
|
|
66
|
+
repeated: repeated.slice(0, 3).map((g) => ({ nudge: g.nudge, n: g.n })),
|
|
67
67
|
debt: debt ? { total: debt.total, testSkips: debt.testSkips, undocumented: debt.undocumented, filesOnFragileCode: debt.filesOnFragileCode, top: (debt.files || []).slice(0, 5) } : null,
|
|
68
68
|
debtError: debtErr,
|
|
69
69
|
}, null, 2));
|
|
@@ -110,6 +110,12 @@ if (steer) {
|
|
|
110
110
|
out.push('');
|
|
111
111
|
out.push(` Steer rate ยท ${steer.rate}% of your recent turns redirected the agent (${steer.scored} scored, recent-window average)`);
|
|
112
112
|
}
|
|
113
|
+
// what you keep saying, across repos: a steer the judge kept 3 times in 2 sessions is a rule waiting to be written
|
|
114
|
+
if (repeated.length) {
|
|
115
|
+
out.push('');
|
|
116
|
+
out.push(' You keep saying ยท write one down if it is a rule (/sawbuck-reins:rules can help)');
|
|
117
|
+
for (const g of repeated.slice(0, 3)) out.push(` ${g.n}x ${g.nudge}`);
|
|
118
|
+
}
|
|
113
119
|
|
|
114
120
|
// Debt
|
|
115
121
|
out.push('');
|
|
@@ -134,8 +140,10 @@ if (debt && debt.total) {
|
|
|
134
140
|
out.push('');
|
|
135
141
|
if (!isGit) {
|
|
136
142
|
out.push(` Not a git repo here, so there is nothing to verify or remember. cd into your project.`);
|
|
143
|
+
} else if (state === 'watching' && wired === 'user' && !hasConfig) {
|
|
144
|
+
out.push(' Measuring here ยท wired in at user level, and that check runs only in a repo that has a promptwheel.config.json. `sawbuck contract` writes one.');
|
|
137
145
|
} else if (state === 'watching') {
|
|
138
|
-
out.push(` Measuring here ยท ${hasConfig ? 'โ wired in with a contract (promptwheel.config.json)' : 'โ wired in (zero-config auto-detect)'}.
|
|
146
|
+
out.push(` Measuring here ยท ${hasConfig ? 'โ wired in with a contract (promptwheel.config.json)' : 'โ wired in (zero-config auto-detect)'}. Protected paths are checked at the end of each turn, and each commit is re-proved from the source.`);
|
|
139
147
|
} else if (state === 'ready') {
|
|
140
148
|
out.push(` Measuring here ยท checkable but not wired yet. Try it now: \`sawbuck check\` โข make it automatic: \`sawbuck attach\``);
|
|
141
149
|
} else {
|
package/steers.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export function readSteers(home = homedir()) {
|
|
|
14
14
|
for (const f of files) {
|
|
15
15
|
try {
|
|
16
16
|
const d = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
17
|
-
if (Array.isArray(d.steers)) out.steers.push(...d.steers);
|
|
17
|
+
if (Array.isArray(d.steers)) out.steers.push(...d.steers.filter((e) => e && typeof e.nudge === 'string')); // a null or half-written entry is not a steer
|
|
18
18
|
if (d.steerStats) { out.stats.scored += d.steerStats.scored || 0; out.stats.lost += d.steerStats.lost || 0; }
|
|
19
19
|
} catch {}
|
|
20
20
|
}
|