@sawbuck/reins 0.0.0-stage → 0.1.0

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/README.md CHANGED
@@ -1,3 +1,66 @@
1
- # Temporary Holding Version
1
+ # reins (engine)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The engine behind reins: measure how much you steer your AI coding agent. It reads
4
+ a Claude Code session transcript, scores each of your turns for **steering
5
+ pressure** (0 passive → 1 light refine → 2 redirect → 3 hard yank) with a small LLM,
6
+ and prints a seismograph of the session plus the distribution.
7
+
8
+ This is the LLM half of reins. The deterministic brake-slam counter (`sawbuck yank`)
9
+ lives in sawbuck's offline core; the continuous pressure judge needs a model, so it
10
+ lives here, keeping sawbuck's core model-free. (The reins *plugin* — the Claude Code
11
+ surface — lives alongside this in `plugins/sawbuck-reins/`.)
12
+
13
+ ```
14
+ $ reins
15
+ reins · steering this session (judge: ollama:qwen2.5:7b)
16
+ ..--+-+---+---.--.--..+--+.--
17
+ . passive - refine + redirect # yank
18
+
19
+ you steered 22 of 29 turns (76%) · mean pressure 0.93/3
20
+ 17 light refine · 5 redirect · 0 hard yank · 7 passive
21
+ the check guards the 7 turns you didn't steer - a miss there is the risk.
22
+ ```
23
+
24
+ ## Backend (SAWBUCK_JUDGE)
25
+
26
+ The judge model is a socket. Set `SAWBUCK_JUDGE` (default `ollama:qwen2.5:7b`):
27
+
28
+ - `ollama:<model>` — local, free, private (nothing leaves your box). `OLLAMA_HOST`
29
+ overrides the endpoint. Benchmarked default `qwen2.5:7b`: fastest, smallest, and
30
+ tied-best accuracy against an opus-labeled gold — model size did not help.
31
+ - `haiku` — Anthropic `claude-haiku-5-5` (needs `ANTHROPIC_API_KEY`); zero VRAM,
32
+ about $0.00005 per turn.
33
+ - `anthropic:<model>` — any Anthropic model.
34
+ - `openai:<model>` — any OpenAI-compatible endpoint (`OPENAI_BASE_URL`,
35
+ `OPENAI_API_KEY`).
36
+
37
+ Output is constrained to a JSON schema where the backend supports it (ollama
38
+ structured outputs), so a thinking model can't break the parse.
39
+
40
+ ## Live on the statusline
41
+
42
+ `--write [path]` writes the summary JSON (default `.sawbuck/steer.json`). The
43
+ offline `sawbuck statusline` reads that file and shows `🤠 steer 76%` next to its
44
+ own segment — the LLM work happens here, sawbuck's core stays model-free (it only
45
+ reads a file). Keep it fresh by running `reins --write` on a cadence (a `/loop`, a
46
+ watch, or a post-turn hook):
47
+
48
+ ```
49
+ reins --write # writes .sawbuck/steer.json; the statusline picks it up
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```
55
+ reins [--session <path.jsonl>] [--judge <backend>] [-n N] [--json] [--write [path]]
56
+ ```
57
+
58
+ Defaults to your newest Claude Code session. `-n` scores only the most recent N
59
+ turns. `--json` is machine-readable. Zero-dep, Node 18+.
60
+
61
+ ## Honest limits
62
+
63
+ A single-turn judge: it sees the agent action and your reply, not the whole thread.
64
+ Models agree on the *shape* (pressure within one level ~95% of the time) more than
65
+ on exact per-turn scores, so read the seismograph and the distribution, not any one
66
+ cell.
package/bin/reins.mjs ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // reins: how much did you steer your agent this session? Reads your Claude Code
3
+ // transcript, scores each turn's steering pressure via SAWBUCK_JUDGE, and prints
4
+ // the seismograph + distribution. The check guards the turns you did not steer.
5
+
6
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { scan, resolveSessionPath, pairs } from "../scan.mjs";
9
+ import { score, backend } from "../judge.mjs";
10
+
11
+ const argv = process.argv.slice(2);
12
+ const flag = (n) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] : null; };
13
+ const has = (n) => argv.includes(n);
14
+
15
+ if (has("--help") || has("-h")) {
16
+ process.stdout.write(`reins: measure how much you steer your agent this session
17
+
18
+ Usage: reins [--session <path.jsonl>] [--judge <backend>] [-n N] [--json] [--write [path]] [--share [--label <term>]]
19
+
20
+ --session <path> a Claude Code transcript (default: your newest session)
21
+ --judge <backend> override SAWBUCK_JUDGE: ollama:<model> | haiku | anthropic:<model> | openai:<model>
22
+ -n <N> score only the most recent N turns
23
+ --json machine-readable output
24
+ --write [path] also write the summary JSON (default .sawbuck/steer.json) for the
25
+ offline sawbuck statusline to read - run it on a cadence for a live readout
26
+ --share print a paste-ready card for posting (LinkedIn / X / Reddit)
27
+ --label <term> the word for the stat on the share card (default "steer rate";
28
+ e.g. "yank rate" for a playful channel - keep it neutral for LinkedIn)
29
+
30
+ Backend is SAWBUCK_JUDGE (default ollama:qwen2.5:7b). Local = free + private.
31
+ `);
32
+ process.exit(0);
33
+ }
34
+
35
+ if (flag("--judge")) process.env.SAWBUCK_JUDGE = flag("--judge");
36
+ const sess = resolveSessionPath(flag("--session"));
37
+ if (!sess) { process.stderr.write("reins: no session transcript found (pass --session <path.jsonl>)\n"); process.exit(2); }
38
+
39
+ let text = "";
40
+ try { text = readFileSync(sess, "utf8"); } catch { process.stderr.write("reins: cannot read " + sess + "\n"); process.exit(2); }
41
+
42
+ // --append: incremental, BOUNDED per-turn scoring for the live watcher. Reads the
43
+ // prior tally from .sawbuck/steer.json, scores only the NEW turn-pairs since the last
44
+ // run, merges, rewrites. One new pair per turn - not a full re-scan (which would be
45
+ // quadratic over a session). The sawbuck statusline reads the same steer.json offline.
46
+ if (has("--append")) {
47
+ const out = join(process.cwd(), ".sawbuck", "steer.json");
48
+ let prior = { lastPairs: 0, scored: 0, steering: 0, wsum: 0, distribution: { 0: 0, 1: 0, 2: 0, 3: 0 } };
49
+ try {
50
+ const p = JSON.parse(readFileSync(out, "utf8"));
51
+ if (p && p.lastPairs != null) prior = { lastPairs: p.lastPairs, scored: p.scored || 0, steering: p.steering || 0, wsum: p._wsum || 0, distribution: p.distribution || prior.distribution };
52
+ } catch { /* no prior tally = fresh */ }
53
+ const all = pairs(text);
54
+ let added = 0;
55
+ for (const { prior: a, user: u } of all.slice(prior.lastPairs)) {
56
+ let s = null; try { s = await score(a, u); } catch {}
57
+ if (!s) continue;
58
+ prior.distribution[s.pressure] = (prior.distribution[s.pressure] || 0) + 1;
59
+ prior.scored++; prior.wsum += s.pressure; added++;
60
+ if (s.pressure >= 1) prior.steering++;
61
+ }
62
+ const rate = prior.scored ? prior.steering / prior.scored : 0;
63
+ const rec = { at: new Date().toISOString(), backend: backend(), lastPairs: all.length, turns: all.length, scored: prior.scored, steering: prior.steering, steeringRate: rate, meanPressure: prior.scored ? prior.wsum / prior.scored : 0, distribution: prior.distribution, _wsum: prior.wsum };
64
+ try { mkdirSync(dirname(out), { recursive: true }); writeFileSync(out, JSON.stringify(rec) + "\n"); } catch { process.stderr.write("reins: could not write " + out + "\n"); }
65
+ if (has("--json")) process.stdout.write(JSON.stringify(rec) + "\n");
66
+ else process.stdout.write(`reins: +${added} new turn(s), steer rate ${Math.round(rate * 100)}% over ${prior.scored} turns\n`);
67
+ process.exit(0);
68
+ }
69
+
70
+ const n = parseInt(flag("-n"), 10) || 0;
71
+ const r = await scan(text, score, { limit: n });
72
+
73
+ // --write [path]: persist the summary so an OFFLINE reader (the sawbuck statusline)
74
+ // can display the live steering number without ever calling a model itself.
75
+ if (has("--write")) {
76
+ const wf = flag("--write");
77
+ const out = wf && !wf.startsWith("--") ? wf : join(process.cwd(), ".sawbuck", "steer.json");
78
+ try {
79
+ mkdirSync(dirname(out), { recursive: true });
80
+ writeFileSync(out, JSON.stringify({ at: new Date().toISOString(), backend: backend(), turns: r.turns, scored: r.scored, steering: r.steering, steeringRate: r.steeringRate, meanPressure: r.meanPressure, distribution: r.distribution }) + "\n");
81
+ } catch { process.stderr.write("reins: could not write " + out + "\n"); }
82
+ }
83
+
84
+ if (has("--json")) { process.stdout.write(JSON.stringify({ backend: backend(), ...r }) + "\n"); process.exit(0); }
85
+
86
+ // --share: a tasteful, paste-anywhere card (plain text works on LinkedIn / X / Reddit).
87
+ // --label swaps the stat's word; default stays neutral ("steer rate") so it's safe for a
88
+ // professional feed, e.g. --label "yank rate" for a playful channel.
89
+ if (has("--share")) {
90
+ if (r.scored === 0) { process.stdout.write("reins: nothing to share yet (no turns scored).\n"); process.exit(0); }
91
+ const lf = flag("--label");
92
+ const label = lf && !lf.startsWith("--") ? lf : "steer rate";
93
+ const pct = Math.round(r.steeringRate * 100);
94
+ const d = r.distribution;
95
+ const texture = d[1] >= d[2] + d[3] ? "mostly light refinements" : d[3] > 0 ? "a few hard pulls" : "real redirects";
96
+ const grip = pct >= 60 ? "hands-on" : pct >= 30 ? "a steady hand" : "a loose rein";
97
+ process.stdout.write(
98
+ `my ${label} this session 🐴
99
+
100
+ ${r.seismograph}
101
+ · let it ride - nudge + redirect # hard pull
102
+
103
+ I steered ${r.steering} of ${r.scored} turns (${pct}%) — ${grip}, ${texture}.
104
+ The ${r.scored - r.steering} I let ride, the check had my back.
105
+
106
+ — measured with reins · sawbuck
107
+ `);
108
+ process.exit(0);
109
+ }
110
+
111
+ const pct = Math.round(r.steeringRate * 100);
112
+ const d = r.distribution;
113
+ process.stdout.write(`\n reins · steering this session (judge: ${backend()})\n`);
114
+ if (r.scored === 0) {
115
+ process.stdout.write(` no turns scored${r.fails ? ` (${r.fails} judge errors - is the backend reachable?)` : ""}.\n\n`);
116
+ process.exit(r.fails ? 2 : 0);
117
+ }
118
+ process.stdout.write(` ${r.seismograph}\n`);
119
+ process.stdout.write(` . passive - refine + redirect # yank\n\n`);
120
+ process.stdout.write(` you steered ${r.steering} of ${r.scored} turns (${pct}%) · mean pressure ${r.meanPressure.toFixed(2)}/3\n`);
121
+ process.stdout.write(` ${d[1]} light refine · ${d[2]} redirect · ${d[3]} hard yank · ${d[0]} passive${r.fails ? ` · ${r.fails} skipped` : ""}\n`);
122
+ process.stdout.write(` the check guards the ${r.scored - r.steering} turns you didn't steer - a miss there is the risk.\n`);
123
+ if (r.recent.length) {
124
+ process.stdout.write(`\n recent steers:\n`);
125
+ for (const s of r.recent) process.stdout.write(` ${["", "-", "+", "#"][s.pressure]}${s.pressure} ${(s.axis || "").padEnd(12)} ${s.nudge}\n`);
126
+ }
127
+ process.stdout.write("\n");
package/judge.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // judge.mjs - the steering-PRESSURE judge's backend socket.
2
+ //
3
+ // score(prior, user) -> {pressure 0-3, axis, nudge}, via the backend SAWBUCK_JUDGE
4
+ // selects. This file does network/LLM calls ON PURPOSE - it is a STANDALONE tool,
5
+ // never imported into sawbuck's offline core (which is stabilizer-enforced
6
+ // model-free). The deterministic yank meter lives in sawbuck; this LLM judge lives
7
+ // here, the way steering lives in the harness layer, not the git core.
8
+ //
9
+ // Backends (SAWBUCK_JUDGE):
10
+ // ollama:<model> local, free, private - the default (ollama:qwen2.5:7b),
11
+ // "put your GPU to work"; OLLAMA_HOST overrides the endpoint.
12
+ // haiku Anthropic claude-haiku-5-5 (needs ANTHROPIC_API_KEY); cheap
13
+ // hosted, zero VRAM (~$0.00005/turn).
14
+ // anthropic:<model> any Anthropic model.
15
+ // openai:<model> any OpenAI-compatible endpoint (OPENAI_BASE_URL + OPENAI_API_KEY).
16
+ //
17
+ // Benchmarked default = qwen2.5:7b: tied-best accuracy vs an opus gold, fastest
18
+ // (0.2s/turn), smallest, 0 parse-fails; model size did not help (see the bench).
19
+
20
+ // JSON Schema that constrains the model's output (ollama structured outputs). This
21
+ // is the robust fix for valid JSON - a grammar, not a prayer - so no backend
22
+ // parse-fails the way free-form prompting did (qwen3's <think> truncation, etc.).
23
+ const PRESSURE_SCHEMA = {
24
+ type: "object",
25
+ properties: {
26
+ pressure: { type: "integer", enum: [0, 1, 2, 3] },
27
+ axis: { type: "string" },
28
+ nudge: { type: "string" },
29
+ },
30
+ required: ["pressure", "axis", "nudge"],
31
+ };
32
+
33
+ const PROMPT = (a, u) =>
34
+ `You rate how much a developer's message STEERS an AI coding agent's current work.
35
+ Output ONLY one JSON object: {"pressure":0-3,"axis":"one-word","nudge":"<=8 words"}
36
+ pressure: 0 passive (approval like "yes/go/do it", a pure question, or a brand-new unrelated task; NOT steering current work). 1 light refine (small adjustment to the SAME work: tone, length, scope, emphasis, "a bit more X", "also Y"). 2 redirect (change the approach/direction of current work). 3 hard-stop ("no/stop/undo/revert", reject and restart).
37
+ AGENT just did: ${a}
38
+ USER replied: ${u}
39
+ JSON:`;
40
+
41
+ // lenient parse: strip reasoning-model <think> blocks, take the last JSON object.
42
+ // (With a constrained-output backend the response is already clean; this is a net.)
43
+ export function parse(txt) {
44
+ txt = String(txt).replace(/<think>[\s\S]*?<\/think>/g, "");
45
+ const cands = txt.match(/\{[^{}]*\}/g) || [];
46
+ for (const c of cands.reverse()) {
47
+ try {
48
+ const j = JSON.parse(c);
49
+ if (j && j.pressure != null) {
50
+ return {
51
+ pressure: Math.max(0, Math.min(3, parseInt(j.pressure, 10) || 0)),
52
+ axis: String(j.axis || "").slice(0, 16),
53
+ nudge: String(j.nudge || "").slice(0, 48),
54
+ };
55
+ }
56
+ } catch { /* keep scanning */ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ async function ollama(model, a, u) {
62
+ const host = process.env.OLLAMA_HOST || "http://localhost:11434";
63
+ const res = await fetch(host + "/api/generate", {
64
+ method: "POST", headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ model, prompt: PROMPT(a, u), stream: false, format: PRESSURE_SCHEMA, options: { temperature: 0, num_predict: 800 } }),
66
+ });
67
+ const j = await res.json();
68
+ return parse(j.response || "");
69
+ }
70
+
71
+ async function anthropic(model, a, u) {
72
+ const key = process.env.ANTHROPIC_API_KEY;
73
+ if (!key) throw new Error("ANTHROPIC_API_KEY not set (SAWBUCK_JUDGE=haiku needs it)");
74
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
75
+ method: "POST", headers: { "Content-Type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
76
+ body: JSON.stringify({ model, max_tokens: 200, messages: [{ role: "user", content: PROMPT(a, u) }] }),
77
+ });
78
+ const j = await res.json();
79
+ const t = (j.content || []).filter((b) => b.type === "text").map((b) => b.text).join("");
80
+ return parse(t);
81
+ }
82
+
83
+ async function openai(model, a, u) {
84
+ const base = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
85
+ const key = process.env.OPENAI_API_KEY;
86
+ if (!key) throw new Error("OPENAI_API_KEY not set");
87
+ const res = await fetch(base.replace(/\/$/, "") + "/chat/completions", {
88
+ method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + key },
89
+ body: JSON.stringify({ model, temperature: 0, max_tokens: 200, response_format: { type: "json_object" }, messages: [{ role: "user", content: PROMPT(a, u) }] }),
90
+ });
91
+ const j = await res.json();
92
+ return parse(j.choices?.[0]?.message?.content || "");
93
+ }
94
+
95
+ export function backend() { return process.env.SAWBUCK_JUDGE || "ollama:qwen2.5:7b"; }
96
+
97
+ export async function score(prior, user) {
98
+ const b = backend();
99
+ if (b === "haiku") return anthropic("claude-haiku-5-5", prior, user);
100
+ if (b.startsWith("anthropic:")) return anthropic(b.slice(10), prior, user);
101
+ if (b.startsWith("ollama:")) return ollama(b.slice(7), prior, user);
102
+ if (b.startsWith("openai:")) return openai(b.slice(7), prior, user);
103
+ throw new Error("unknown SAWBUCK_JUDGE backend: " + b + " (use ollama:<model> | haiku | anthropic:<model> | openai:<model>)");
104
+ }
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "@sawbuck/reins",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Reins: measure how much you steer your AI coding agent. A per-turn steering-pressure judge over your session transcript, pluggable backend (Haiku / local ollama / any OpenAI-compatible). The LLM half of the reins steering layer; keeps sawbuck's core offline.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "reins": "bin/reins.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test"
12
+ },
13
+ "files": [
14
+ "bin/reins.mjs",
15
+ "judge.mjs",
16
+ "scan.mjs",
17
+ "README.md"
18
+ ]
19
+ }
package/scan.mjs ADDED
@@ -0,0 +1,103 @@
1
+ // scan.mjs - turn a Claude Code session transcript into a steering-pressure
2
+ // reading: score each of your turns (given the agent action it followed) and
3
+ // aggregate into a distribution + a seismograph. The scorer is injected so the
4
+ // aggregation is testable without a live model.
5
+
6
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+
10
+ function humanText(o) {
11
+ if (!o || o.type !== "user" || o.isMeta) return null;
12
+ if (o.userType && o.userType !== "external") return null;
13
+ const c = o.message && o.message.content;
14
+ if (typeof c === "string") return c;
15
+ if (Array.isArray(c)) {
16
+ if (c.some((b) => b && b.type === "tool_result")) return null;
17
+ const t = c.filter((b) => b && b.type === "text").map((b) => b.text || "").join(" ");
18
+ return t || null;
19
+ }
20
+ return null;
21
+ }
22
+ function assistantText(o) {
23
+ const c = o.message && o.message.content;
24
+ if (Array.isArray(c)) return c.filter((b) => b && b.type === "text").map((b) => b.text || "").join(" ").trim();
25
+ if (typeof c === "string") return c.trim();
26
+ return "";
27
+ }
28
+ const noise = (t) => {
29
+ const s = t.trim();
30
+ if (s.includes("<task-notification>") || s.startsWith("<task-id>")) return true;
31
+ if (s.replace(/\[Image #\d+\]/g, "").trim().length < 3) return true;
32
+ return false;
33
+ };
34
+
35
+ // Extract the (prior-agent, user-turn) pairs to score: every non-opener human turn
36
+ // that followed an agent action.
37
+ export function pairs(text) {
38
+ const out = [];
39
+ let prev = "", seen = false;
40
+ for (const line of String(text).split("\n")) {
41
+ if (!line.trim()) continue;
42
+ let o; try { o = JSON.parse(line); } catch { continue; }
43
+ if (o.type === "assistant") { prev = assistantText(o); continue; }
44
+ const t = humanText(o);
45
+ if (t === null || noise(t)) continue;
46
+ if (!seen) { seen = true; continue; } // opener = a request, not a steer
47
+ if (prev) out.push({ prior: prev.slice(0, 600), user: t.replace(/\s+/g, " ").slice(0, 800) });
48
+ prev = "";
49
+ }
50
+ return out;
51
+ }
52
+
53
+ const GLYPH = { 0: ".", 1: "-", 2: "+", 3: "#" };
54
+
55
+ // scan(text, scoreFn) -> aggregate. scoreFn(prior, user) -> {pressure, axis, nudge} | null.
56
+ export async function scan(text, scoreFn, { limit = 0 } = {}) {
57
+ let ps = pairs(text);
58
+ if (limit > 0 && ps.length > limit) ps = ps.slice(-limit); // most recent N
59
+ const dist = { 0: 0, 1: 0, 2: 0, 3: 0 };
60
+ let scored = 0, fails = 0, wsum = 0, spark = "";
61
+ const recent = [];
62
+ for (const { prior, user } of ps) {
63
+ let r = null;
64
+ try { r = await scoreFn(prior, user); } catch { r = null; }
65
+ if (!r) { fails++; spark += "?"; continue; }
66
+ dist[r.pressure]++; scored++; wsum += r.pressure; spark += GLYPH[r.pressure];
67
+ if (r.pressure >= 1) recent.push({ pressure: r.pressure, axis: r.axis, nudge: r.nudge, user: user.slice(0, 60) });
68
+ }
69
+ const turns = ps.length;
70
+ const steering = dist[1] + dist[2] + dist[3];
71
+ return {
72
+ turns, scored, fails,
73
+ distribution: dist,
74
+ steering, steeringRate: scored ? steering / scored : 0,
75
+ meanPressure: scored ? wsum / scored : 0,
76
+ seismograph: spark,
77
+ recent: recent.slice(-6),
78
+ };
79
+ }
80
+
81
+ // Resolve the newest session transcript for a cwd (Claude Code: ~/.claude/projects/
82
+ // <encoded-cwd>/<id>.jsonl). Best-effort; prefer the cwd's dir, else newest overall.
83
+ export function resolveSessionPath(explicit, cwd = process.cwd()) {
84
+ if (explicit) return existsSync(explicit) ? explicit : null;
85
+ const root = join(homedir(), ".claude", "projects");
86
+ if (!existsSync(root)) return null;
87
+ const key = cwd.replace(/[^A-Za-z0-9]/g, "-");
88
+ let dirs = [];
89
+ try { dirs = readdirSync(root).map((d) => join(root, d)).filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } }); }
90
+ catch { return null; }
91
+ const matched = dirs.filter((p) => p.endsWith("/" + key));
92
+ const pool = matched.length ? matched : dirs;
93
+ let best = null, bestT = 0;
94
+ for (const full of pool) {
95
+ let files = []; try { files = readdirSync(full); } catch { continue; }
96
+ for (const f of files) {
97
+ if (!f.endsWith(".jsonl")) continue;
98
+ const p = join(full, f);
99
+ try { const st = statSync(p); if (st.mtimeMs > bestT) { bestT = st.mtimeMs; best = p; } } catch {}
100
+ }
101
+ }
102
+ return best;
103
+ }