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.
- package/package.json +2 -1
- package/sawbuck.mjs +36 -1
- package/yank.mjs +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sawbuck",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
+
}
|