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/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
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, rmSync, copyFileSync } from 'node:fs';
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 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') });
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
- writeFileSync(p.input, pick.map((l, i) => `${i + 1}\t${l}`).join('\n') + '\n', { mode: 0o600 });
24
- return { input: p.input, lines: pick.length, secrets: r.secrets };
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
- if (existsSync(p.hist)) { const l = readFileSync(p.hist, 'utf8').trim().split('\n').filter(Boolean); if (l.length) prev = JSON.parse(l[l.length - 1]); }
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)'}. It verifies each commit/turn automatically.`);
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
  }