sawbuck 0.1.45 โ†’ 0.1.47

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 } 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) {
@@ -68,18 +147,22 @@ function pngCard(svg) {
68
147
  }
69
148
 
70
149
  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` : ''}.`; }
150
+ 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
151
  const i = args.indexOf('--check');
73
152
  if (i === -1) return 'sawbuck shape --prepare | --check <labels.json>';
74
153
  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
154
  const p = paths(home);
155
+ // a check that cannot run still clears the excerpts: they are only for this one read
156
+ const stop = (msg) => { rmSync(p.turns, { force: true }); rmSync(p.notsaid, { force: true }); return msg; };
157
+ if (!file || !existsSync(file)) return stop('๐Ÿด sawbuck: --check needs the labels file.');
158
+ let labels = {};
159
+ try { const m = readFileSync(file, 'utf8').match(/\{[\s\S]*\}/); labels = m ? JSON.parse(m[0]) : {}; } catch {}
160
+ const t = tally(labels && typeof labels === 'object' ? labels : {});
161
+ if (!t.n) return stop('๐Ÿด sawbuck: no usable labels in that file.');
80
162
  mkdirSync(p.dir, { recursive: true });
81
163
  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]); }
164
+ try { const l = readFileSync(p.hist, 'utf8').trim().split('\n').filter(Boolean); if (l.length) prev = JSON.parse(l[l.length - 1]); } catch {}
165
+ if (!prev || typeof prev !== 'object' || !prev.groups) prev = null;
83
166
  writeFileSync(p.svg, shapeSvg(t));
84
167
  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
168
  for (const [g, ss] of Object.entries(GROUPS)) {
@@ -89,6 +172,7 @@ export function shapeCli(args, { home = homedir() } = {}) {
89
172
  }
90
173
  if (!t.spokes) out.push('', ` Too few messages for the 11 spokes (${t.n} of ${MIN_SPOKES}), so this shows the 4 groups.`);
91
174
  appendFileSync(p.hist, JSON.stringify({ at: new Date().toISOString(), n: t.n, groups: t.groups, shares: t.shares }) + '\n');
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,11 +8,13 @@
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; };
@@ -28,7 +30,8 @@ const ICON = { verified: 'โœ“', gamed: '๐Ÿšฉ', regression: 'โœ—', inconclusive:
28
30
  // --- 1) the outcome record: what the check has done here -------------------
29
31
  // results.json holds the all-time catch tally (gamed + regression, never trimmed); activity.jsonl is
30
32
  // 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);
33
+ const { results, caught: catchesAllTime, state: kind, steer, armed, hasConfig, wired } = repoState(repo);
34
+ const repeated = groupSteers(readSteers().steers).filter((g) => g.n >= 3 && g.sessions >= 2);
32
35
  const feed = readLines(join(repo, '.sawbuck', 'activity.jsonl')).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
33
36
  const tally = { verified: 0, gamed: 0, regression: 0, inconclusive: 0, allowed: 0, mandate: 0 };
34
37
  for (const e of feed) if (e && tally[e.kind] != null) tally[e.kind]++;
@@ -64,6 +67,7 @@ if (asJson) {
64
67
  config: hasConfig,
65
68
  outcomes: { seen, caughtAllTime: catchesAllTime, gamedAllTime: results.gamed || 0, regressionsAllTime: results.regressions || 0, recent: tally, latest, since },
66
69
  steer,
70
+ repeated: repeated.slice(0, 3).map((g) => ({ nudge: g.nudge, n: g.n })),
67
71
  debt: debt ? { total: debt.total, testSkips: debt.testSkips, undocumented: debt.undocumented, filesOnFragileCode: debt.filesOnFragileCode, top: (debt.files || []).slice(0, 5) } : null,
68
72
  debtError: debtErr,
69
73
  }, null, 2));
@@ -110,6 +114,12 @@ if (steer) {
110
114
  out.push('');
111
115
  out.push(` Steer rate ยท ${steer.rate}% of your recent turns redirected the agent (${steer.scored} scored, recent-window average)`);
112
116
  }
117
+ // what you keep saying, across repos: a steer the judge kept 3 times in 2 sessions is a rule waiting to be written
118
+ if (repeated.length) {
119
+ out.push('');
120
+ out.push(' You keep saying ยท write one down if it is a rule (/sawbuck-reins:rules can help)');
121
+ for (const g of repeated.slice(0, 3)) out.push(` ${g.n}x ${g.nudge}`);
122
+ }
113
123
 
114
124
  // Debt
115
125
  out.push('');
@@ -134,6 +144,8 @@ if (debt && debt.total) {
134
144
  out.push('');
135
145
  if (!isGit) {
136
146
  out.push(` Not a git repo here, so there is nothing to verify or remember. cd into your project.`);
147
+ } else if (state === 'watching' && wired === 'user' && !hasConfig) {
148
+ 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
149
  } else if (state === 'watching') {
138
150
  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.`);
139
151
  } else if (state === 'ready') {
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
  }
@@ -37,7 +37,7 @@ export function steersReport({ json = false, home } = {}) {
37
37
  const groups = groupSteers(steers);
38
38
  const repeated = groups.filter((g) => g.n >= 3 && g.sessions >= 2);
39
39
  if (json) return JSON.stringify({ steers: steers.length, scored: stats.scored, unscored: stats.lost, repeated });
40
- if (!steers.length && !stats.scored) return '๐Ÿด sawbuck: no steers recorded. The plugin\'s live judge is off by default; /sawbuck-reins:rules reads your history instead.';
40
+ if (!steers.length && !stats.scored) return '๐Ÿด sawbuck: no steers recorded yet. They appear as you work with the plugin\'s live judge on.';
41
41
  const out = [`๐Ÿด ${steers.length} steers kept, from ${stats.scored} replies scored${stats.lost ? ` (${stats.lost} could not be scored, so this undercounts)` : ''}. Measured, not estimated.`];
42
42
  if (repeated.length) {
43
43
  out.push('', 'You keep saying:');