sawbuck 0.1.21 → 0.1.22

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.
Files changed (3) hide show
  1. package/package.json +2 -1
  2. package/sawbuck.mjs +36 -1
  3. package/yank.mjs +87 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Sign your agent to a contract so it does what you want: a deterministic, zero-LLM check that re-proves every win from the agent's source edits alone, so a green that came from editing the test is caught and reverted. A saddle for your coding harness.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  "debt.mjs",
29
29
  "status.mjs",
30
30
  "memory.mjs",
31
+ "yank.mjs",
31
32
  "providers/obsidian.mjs",
32
33
  "providers/mem0.mjs",
33
34
  "providers/local.mjs",
package/sawbuck.mjs CHANGED
@@ -19,6 +19,7 @@ import { fileURLToPath } from 'node:url';
19
19
  import { homedir } from 'node:os';
20
20
  import { createRequire } from 'node:module';
21
21
  import { recall as memRecall, conform as memConform } from './memory.mjs';
22
+ import { tally, resolveSessionPath } from './yank.mjs';
22
23
 
23
24
  const SELF = fileURLToPath(import.meta.url);
24
25
  const HERE = dirname(SELF);
@@ -98,6 +99,10 @@ Usage:
98
99
  shows only the debt a branch adds; --fail-on exits nonzero for a CI gate
99
100
  sawbuck run --attempt '<cmd>' [--loops N]
100
101
  Thin harness: load context, run one attempt through the keep-if-verified gate
102
+ sawbuck yank [--session <path>] [--json]
103
+ The steering meter: how much you had to steer (yank the reins) this session,
104
+ read from your Claude Code transcript. The check guards the turns you did not
105
+ steer — a miss there is the risk. Yank rate falling = the reins getting looser.
101
106
  sawbuck --help | --version
102
107
 
103
108
  Rides two engines: promptwheel (the check) + @promptwheel/logbook (memory).`;
@@ -264,11 +269,41 @@ switch (cmd) {
264
269
  const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
265
270
  const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
266
271
  const seg = caught > 0 ? `🐴 sawbuck · ${caught} caught` : active ? `🐴 sawbuck · watching` : armed ? `🐴 sawbuck · ready` : mem ? `🐴 sawbuck · memory` : '';
267
- if (seg) process.stdout.write(last ? `${seg} · ${last}` : seg);
272
+ const base = seg ? (last ? `${seg} · ${last}` : seg) : '';
273
+ // the live steering readout, when the wrapper forwards the session transcript path
274
+ let yk = '';
275
+ const ti = rest.findIndex((a) => a === '--transcript' || a === '--session');
276
+ if (ti > -1 && rest[ti + 1]) { try { const { yanks, turns } = tally(readFileSync(rest[ti + 1], 'utf8')); if (turns) yk = `🤠 ${yanks}/${turns} yanks`; } catch {} }
277
+ // live steering % from the reins engine's file (OFFLINE read; reins does the LLM work, never sawbuck)
278
+ let st = '';
279
+ try { const sj = JSON.parse(readFileSync(join(repo, '.sawbuck', 'steer.json'), 'utf8')); if (sj && sj.scored) st = `🤠 steer ${Math.round((sj.steeringRate || 0) * 100)}%`; } catch {}
280
+ const outp = [base, yk, st].filter(Boolean).join(' · ');
281
+ if (outp) process.stdout.write(outp);
268
282
  }
269
283
  } catch {}
270
284
  break;
271
285
  }
286
+ case 'yank': {
287
+ // the live steering meter (the reins hook): how much did you have to steer this session?
288
+ // A MIRROR read from the Claude Code transcript; the check guards the turns you did NOT steer.
289
+ const si = rest.findIndex((a) => a === '--session' || a === '--transcript');
290
+ const sess = resolveSessionPath(si > -1 ? rest[si + 1] : null, process.cwd());
291
+ if (!sess) { out('🐴 sawbuck: no session transcript found to read (pass --session <path.jsonl>).'); process.exit(2); }
292
+ let text = ''; try { text = readFileSync(sess, 'utf8'); } catch { out('🐴 sawbuck: could not read the session transcript.'); process.exit(2); }
293
+ const { turns, yanks, rate, recent } = tally(text);
294
+ if (flags.has('--json')) { out(JSON.stringify({ turns, yanks, rate, recent })); break; }
295
+ const pct = Math.round(rate * 100);
296
+ out('🐴 reins: steering this session');
297
+ if (yanks === 0) {
298
+ out(` 0 hard yanks across ${turns} turns — you did not have to pull the reins hard.`);
299
+ } else {
300
+ out(` ${yanks} hard yank${yanks === 1 ? '' : 's'} across ${turns} turns (${pct}%) — times you pulled me back hard.`);
301
+ if (turns - yanks > 0) out(` the check guards the other ${turns - yanks} turns you trusted me with — a miss there is the risk.`);
302
+ if (recent.length) out(` recent: ${recent.map((r) => '"' + r + '"').join(' · ')}`);
303
+ }
304
+ out(' (terse course-corrections only; the full steer rate, incl. discursive redirects, is the harvest layer.)');
305
+ break;
306
+ }
272
307
  default: die(`unknown command '${cmd}'\n\n${help}`);
273
308
  }
274
309
 
package/yank.mjs ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ // yank.mjs — the live steering meter (the "reins" hook). Reads the current Claude
3
+ // Code session transcript and counts YANKS: turns where you corrected or redirected
4
+ // the agent. Deterministic, zero-dep, with a transparent definition shown in the
5
+ // output. It is a MIRROR — a number about how much you had to steer this session —
6
+ // and it pairs with the check: the check guards the turns you did NOT steer, where
7
+ // a miss is the real risk. The yank rate falling over time is the reins getting
8
+ // looser (the agent learning your cues).
9
+ //
10
+ // A yank is counted CONSERVATIVELY (precision over recall): a human turn, after the
11
+ // agent has acted, that OPENS by correcting or redirecting ("no", "don't",
12
+ // "actually", "stop", "undo", "instead", ...). Discursive steers buried mid-
13
+ // paragraph are deliberately NOT counted here — that is the LLM-harvest layer's job;
14
+ // this stays cheap, instant, and honest (an undercount with a clear definition beats
15
+ // an inflated guess).
16
+
17
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+
21
+ // leading correction/redirect markers at the START of the turn (case-insensitive)
22
+ const LEAD = /^\s*["'(]?\s*(no\b|no,|nope\b|don'?t\b|do not\b|actually\b|wait\b|stop\b|undo\b|revert\b|instead\b|nah\b|hold on|rather\b|not (that|like|quite|what|really)|that'?s (not|wrong)|hmm,? ?no|why (did|are) you|you (shouldn'?t|weren'?t))/i;
23
+
24
+ function humanText(o) {
25
+ if (!o || o.type !== 'user' || o.isMeta) return null;
26
+ if (o.userType && o.userType !== 'external') return null;
27
+ const c = o.message && o.message.content;
28
+ if (typeof c === 'string') return c;
29
+ if (Array.isArray(c)) {
30
+ if (c.some((b) => b && b.type === 'tool_result')) return null; // tool output, not you
31
+ const t = c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join(' ');
32
+ return t || null;
33
+ }
34
+ return null;
35
+ }
36
+ const isNoise = (t) => {
37
+ const s = t.trim();
38
+ if (s.includes('<task-notification>') || s.startsWith('<task-id>')) return true; // injected
39
+ if (s.replace(/\[Image #\d+\]/g, '').trim().length < 3) return true; // image-only
40
+ return false;
41
+ };
42
+
43
+ // tally(transcriptText) -> { turns, yanks, rate, recent } over YOUR typed turns.
44
+ export function tally(text) {
45
+ let turns = 0, yanks = 0, seenHuman = false, prevAssistantActed = false;
46
+ const recent = [];
47
+ for (const line of String(text).split('\n')) {
48
+ if (!line.trim()) continue;
49
+ let o; try { o = JSON.parse(line); } catch { continue; }
50
+ if (o.type === 'assistant') { prevAssistantActed = true; continue; }
51
+ const t = humanText(o);
52
+ if (t === null || isNoise(t)) continue;
53
+ const opener = !seenHuman; seenHuman = true;
54
+ turns++;
55
+ if (!opener && prevAssistantActed && LEAD.test(t)) {
56
+ yanks++;
57
+ recent.push(t.replace(/\s+/g, ' ').trim().slice(0, 48));
58
+ }
59
+ prevAssistantActed = false;
60
+ }
61
+ return { turns, yanks, rate: turns ? yanks / turns : 0, recent: recent.slice(-5) };
62
+ }
63
+
64
+ // Resolve the newest session transcript for a cwd. Claude Code stores transcripts
65
+ // under ~/.claude/projects/<encoded-cwd>/<id>.jsonl (path separators -> dashes).
66
+ // Best-effort: prefer the dir matching this cwd, else the newest across all. Never throws.
67
+ export function resolveSessionPath(explicit, cwd = process.cwd()) {
68
+ if (explicit) return existsSync(explicit) ? explicit : null;
69
+ const root = join(homedir(), '.claude', 'projects');
70
+ if (!existsSync(root)) return null;
71
+ const key = cwd.replace(/[^A-Za-z0-9]/g, '-');
72
+ let dirs = [];
73
+ try { dirs = readdirSync(root).map((d) => join(root, d)).filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } }); }
74
+ catch { return null; }
75
+ const matched = dirs.filter((p) => p.endsWith('/' + key));
76
+ const pool = matched.length ? matched : dirs;
77
+ let best = null, bestT = 0;
78
+ for (const full of pool) {
79
+ let files = []; try { files = readdirSync(full); } catch { continue; }
80
+ for (const f of files) {
81
+ if (!f.endsWith('.jsonl')) continue;
82
+ const p = join(full, f);
83
+ try { const st = statSync(p); if (st.mtimeMs > bestT) { bestT = st.mtimeMs; best = p; } } catch {}
84
+ }
85
+ }
86
+ return best;
87
+ }