sawbuck 0.1.20 → 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/GLOSSARY.md CHANGED
@@ -14,7 +14,7 @@ it is plain and scientific. The saddle is the brand and the architecture spine
14
14
  (the name, `attach`, `fit`); it is NOT a vocabulary for every concept.
15
15
 
16
16
  Why plain wins everywhere: an LLM has strong trained priors on words like
17
- `regression`, `verified`, `invalid`. Metaphor jargon in the model's context forces
17
+ `regression`, `verified`, `gamed`. Metaphor jargon in the model's context forces
18
18
  a gloss every session (token cost) and risks a misread (trajectory drift). Plain,
19
19
  high-prior words serve the human reader and the agent equally, so we do not keep
20
20
  two vocabularies.
@@ -28,7 +28,7 @@ two vocabularies.
28
28
  | the verifier | **the check** (verb: *verify*) | runs the metrics on a change |
29
29
  | a change that improved and holds | **verified** | |
30
30
  | a change that made a guarded metric worse | **regression** | do-not-retry |
31
- | a change that passed by weakening its test | **invalid** | the test was weakened, not the metric improved (engine verdict: `gamed`) |
31
+ | a change that passed by faking the metric | **gamed** | the win came from editing the test, config, or grader, not the source (the engine verdict, and the word every Sawbuck surface uses) |
32
32
  | the accumulated record of outcomes | **results** | `results.json` |
33
33
  | what Sawbuck leaves you | **verified history** | the findings you can trust |
34
34
  | a surfaced past outcome | **finding** | |
@@ -61,12 +61,13 @@ Findings are **leads, not conclusions**, verify against the diff before acting.
61
61
  ## Dispatch template
62
62
 
63
63
  ```
64
- Sawbuck · {N} regressions charted · {N} invalid changes caught
64
+ Sawbuck · {N} regressions charted · {N} gamed changes caught
65
65
  🆕 just learned: {reason} ({file})
66
66
  ```
67
67
 
68
68
  ## Note on engine verdicts
69
69
 
70
- The check engine still emits its own verdicts (`gamed`, `fail`, `pass`); Sawbuck
71
- maps them to the plain outcome words above (`gamed` → **invalid**, `fail` →
72
- **regression**). Engine-internal names are not part of this surface.
70
+ The check engine emits verdicts (`gamed`, `fail`, `pass`); the surface keeps **gamed**
71
+ as-is (it is vivid and used on every surface) and maps `fail` → **regression** and
72
+ `pass` → **verified**. ("Invalid" was an earlier softening of `gamed` that no surface
73
+ adopted; `gamed` is canonical.)
package/README.md CHANGED
@@ -48,6 +48,17 @@ sawbuck: gamed recorded on test.js (the win came from editing the test, no
48
48
  | `sawbuck recall` | related notes for your current change from extra memory providers you compose in (bring your own) |
49
49
  | `sawbuck run --attempt '<agent cmd>'` | thin harness: load context, run one attempt through the keep-if-verified gate |
50
50
  | `sawbuck trace` · `sawbuck doctor` · `detach --verify` | show the footprint; check the attachment resolves; prove a clean removal |
51
+ | `sawbuck where` · `sawbuck statusline` | list every repo it is attached to; a one-line status-line segment (🐴 watching / N caught) |
52
+
53
+ ## Give your agent the contract
54
+
55
+ `sawbuck init` writes this block into your `AGENTS.md` for you (and `CLAUDE.md` / `.cursorrules` if present; `--no-agent-block` to skip), and `sawbuck attach --claude` enforces the same thing every turn via the hook. Here it is to paste yourself if you want:
56
+
57
+ This repo is under a Sawbuck contract: a change is done only when the win survives verification
58
+ from the source alone. Before finishing, run `sawbuck check`. If it reads `gamed`, the green came
59
+ from editing a test, config, or grader, not the source, so undo that and earn it in the source.
60
+ Run `sawbuck context` first to see what broke in these files before (reverts, weakened tests,
61
+ fragile spots). Never skip, weaken, or delete a test to go green.
51
62
 
52
63
  Each repo needs a `promptwheel.config.json` saying what to measure (your tests, lint, coverage, or an eval).
53
64
 
package/init.mjs CHANGED
@@ -32,7 +32,7 @@ const engineBin = (envVar, pkgSubpath, devPath) => {
32
32
  for (const spec of [pkgSubpath, bare]) { try { return require.resolve(spec); } catch {} }
33
33
  return devPath;
34
34
  };
35
- const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'referee', 'bin', 'promptwheel.mjs'));
35
+ const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'promptwheel', 'bin', 'promptwheel.mjs'));
36
36
  const LB = engineBin('LB_BIN', '@promptwheel/logbook/bin/logbook.mjs', join(HERE, '..', 'logbook', 'bin', 'logbook.mjs'));
37
37
 
38
38
  const argv = process.argv.slice(2);
@@ -44,6 +44,7 @@ const yes = has('--yes') || has('-y');
44
44
  const forceWizard = has('--wizard');
45
45
  const investigate = !has('--no-investigate');
46
46
  const force = has('--force');
47
+ const noBlock = has('--no-agent-block');
47
48
  const presetIdx = argv.indexOf('--preset');
48
49
  const preset = presetIdx > -1 ? argv[presetIdx + 1] : null;
49
50
 
@@ -54,7 +55,9 @@ const prior = priorExisted ? readFileSync(cfgPath, 'utf8') : null;
54
55
  const restore = () => { try { if (prior != null) writeFileSync(cfgPath, prior); else if (existsSync(cfgPath)) unlinkSync(cfgPath); } catch {} };
55
56
 
56
57
  // 1) base contract from promptwheel init (stack-detected tests + antihack tripwires)
57
- const pwArgs = ['init', '--force'];
58
+ // --no-agent-block: suppress promptwheel's own AGENTS.md gate block here; sawbuck writes its own
59
+ // sawbuck-flavored contract block below, so the user does not get two overlapping agent guides.
60
+ const pwArgs = ['init', '--force', '--no-agent-block'];
58
61
  if (preset) pwArgs.push('--preset', preset);
59
62
  try { execFileSync(process.execPath, [PW, ...pwArgs], { cwd: repo, stdio: 'ignore' }); }
60
63
  catch { restore(); out('sawbuck init: could not generate the base contract (promptwheel init failed).'); process.exit(1); }
@@ -115,8 +118,29 @@ function describeMetric(m) {
115
118
 
116
119
  function writeCfg() { writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n'); }
117
120
 
121
+ // Write (or refresh) the agent-orienting contract block so a coding agent (or a loop not wired to the
122
+ // `attach --claude` hook) knows the rules. AGENTS.md (the cross-tool convention; created if absent) +
123
+ // CLAUDE.md / .cursorrules when present. Idempotent + safe: only the sentinel-delimited block is
124
+ // touched. `--no-agent-block` skips it.
125
+ function writeAgentBlock() {
126
+ const START = '<!-- sawbuck:contract -->', END = '<!-- /sawbuck:contract -->';
127
+ const block = `${START}\n## Sawbuck contract\n\nThis repo is under a Sawbuck contract: a change is done only when the win survives verification from the source alone. Before finishing, run \`sawbuck check\`. If it reads \`gamed\`, the green came from editing a test, config, or grader, not the source, so undo that and earn it in the source. Run \`sawbuck context\` first to see what broke in these files before (reverts, weakened tests, fragile spots). Never skip, weaken, or delete a test to go green.\n${END}\n`;
128
+ const targets = ['AGENTS.md', ...['CLAUDE.md', '.cursorrules'].filter((f) => existsSync(join(repo, f)))];
129
+ const done = [];
130
+ for (const f of targets) {
131
+ const p = join(repo, f);
132
+ let text = existsSync(p) ? readFileSync(p, 'utf8') : '';
133
+ const s = text.indexOf(START);
134
+ if (s > -1) { const e = text.indexOf(END, s); text = (e > -1 ? text.slice(0, s) + block + text.slice(e + END.length).replace(/^\n+/, '\n') : text.slice(0, s) + block); }
135
+ else { text = (text.trim() ? text.replace(/\n*$/, '\n\n') : '') + block; }
136
+ try { writeFileSync(p, text); done.push(f); } catch { /* best-effort */ }
137
+ }
138
+ return done;
139
+ }
140
+
118
141
  function report() {
119
142
  out('sawbuck: wrote promptwheel.config.json');
143
+ if (!noBlock) { const w = writeAgentBlock(); if (w.length) out(`sawbuck: wrote the agent guide (the contract rules) to ${w.join(', ')}`); }
120
144
  out("\n the contract (what a win must satisfy):");
121
145
  for (const m of cfg.metrics) out(' - ' + describeMetric(m));
122
146
  if (evidence) { out('\n investigated this repo:'); for (const l of evidenceLines()) out(l); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sawbuck",
3
- "version": "0.1.20",
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/reflect.mjs CHANGED
@@ -34,7 +34,7 @@ const engineBin = (envVar, pkgSubpath, devPath) => {
34
34
  for (const spec of [pkgSubpath, bare]) { try { return require.resolve(spec); } catch {} }
35
35
  return devPath;
36
36
  };
37
- const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'referee', 'bin', 'promptwheel.mjs'));
37
+ const PW = engineBin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'promptwheel', 'bin', 'promptwheel.mjs'));
38
38
 
39
39
  const flag = (name, def) => { const i = process.argv.indexOf(name); return i > -1 ? process.argv[i + 1] : def; };
40
40
  const repo = flag('--repo', process.cwd());
package/run.mjs CHANGED
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
16
16
  const HERE = dirname(fileURLToPath(import.meta.url));
17
17
  const require = createRequire(import.meta.url);
18
18
  const bin = (envVar, pkg, dev) => { if (process.env[envVar]) return process.env[envVar]; const bare = pkg.replace(/\/bin\/[^/]*$/, ''); for (const s of [pkg, bare]) { try { return require.resolve(s); } catch {} } return dev; };
19
- const PW = bin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'referee', 'bin', 'promptwheel.mjs'));
19
+ const PW = bin('PW_BIN', 'promptwheel/bin/promptwheel.mjs', join(HERE, '..', 'promptwheel', 'bin', 'promptwheel.mjs'));
20
20
 
21
21
  const arg = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
22
22
  const repo = arg('--repo', process.cwd());
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);
@@ -27,7 +28,7 @@ const require = createRequire(import.meta.url);
27
28
  // resolve the promptwheel engine bin: env override, installed package, then the dev sibling
28
29
  const PW = process.env.PW_BIN
29
30
  || ['promptwheel/bin/promptwheel.mjs', 'promptwheel'].reduce((r, s) => { try { return r || require.resolve(s); } catch { return r; } }, '')
30
- || join(HERE, '..', 'referee', 'bin', 'promptwheel.mjs');
31
+ || join(HERE, '..', 'promptwheel', 'bin', 'promptwheel.mjs');
31
32
  // run the engine and parse its JSON verdict (it prints JSON even when it exits nonzero)
32
33
  function gateReport(repo, a) {
33
34
  let o = '', code = 0;
@@ -63,10 +64,11 @@ try { version = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8')).ver
63
64
  const help = `sawbuck ${version}: a saddle for your repo
64
65
 
65
66
  Usage:
66
- sawbuck init [--yes] [--wizard] [--no-investigate]
67
+ sawbuck init [--yes] [--wizard] [--no-investigate] [--no-agent-block]
67
68
  Generate the contract (promptwheel.config.json): a stack starter, tuned and
68
- explained by what your git history shows actually breaks. Wizard in a terminal,
69
- non-interactive in a pipe/CI/agent (or with --yes).
69
+ explained by what your git history shows actually breaks. Also writes a short
70
+ contract guide into AGENTS.md so your agent knows the rules (--no-agent-block
71
+ to skip). Wizard in a terminal, non-interactive in a pipe/CI/agent (or --yes).
70
72
  sawbuck attach [repo] Wire Sawbuck into a repo (default: a git post-commit hook)
71
73
  --claude instead wire project Claude Code hooks (writes .claude/settings.local.json)
72
74
  --shared with --claude, write the tracked .claude/settings.json (team-shared, visible in git)
@@ -97,6 +99,10 @@ Usage:
97
99
  shows only the debt a branch adds; --fail-on exits nonzero for a CI gate
98
100
  sawbuck run --attempt '<cmd>' [--loops N]
99
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.
100
106
  sawbuck --help | --version
101
107
 
102
108
  Rides two engines: promptwheel (the check) + @promptwheel/logbook (memory).`;
@@ -125,8 +131,13 @@ switch (cmd) {
125
131
  // zero-config (the engine auto-detects your test command), and say plainly whether the win was
126
132
  // earned. No attach, no hook, no record. The try-it-first on-ramp.
127
133
  const repo = repoArg();
128
- const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8' }).trim(); } catch { return ''; } };
134
+ // stderr ignored so a benign failing probe (e.g. rev-parse HEAD~1 on a one-commit repo) does not
135
+ // leak a scary "fatal:" line before sawbuck's own message.
136
+ const g = (...a) => { try { return execFileSync('git', ['-C', repo, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { return ''; } };
129
137
  const dirty = g('status', '--porcelain').length > 0;
138
+ // nothing to verify yet: a clean tree with no previous commit to diff against (a fresh repo). Say so
139
+ // plainly instead of the generic "could not run" below, so the first run is not a dead end.
140
+ if (!dirty && !g('rev-parse', '--verify', 'HEAD~1')) { out('🐴 sawbuck: nothing to verify yet, the working tree is clean and this is the only commit. Make a change (or add one more commit), then `sawbuck check` re-proves the win from your source edits alone.'); process.exit(2); }
130
141
  const where = dirty ? 'working' : 'last';
131
142
  const { rep, code } = gateReport(repo, dirty ? ['run', '--working'] : ['run', '--base', 'HEAD~1', '--head', 'HEAD']);
132
143
  if (!rep || !rep.verdict) { out('🐴 sawbuck: could not run the check here. Make sure this is a git repo with a commit, and a test command (or run `sawbuck init`).'); process.exit(2); }
@@ -258,11 +269,41 @@ switch (cmd) {
258
269
  const armed = existsSync(join(repo, 'promptwheel.config.json')) || existsSync(join(repo, 'package.json'));
259
270
  const mem = existsSync(join(repo, 'LOGBOOK.md')) || existsSync(join(repo, 'events.jsonl'));
260
271
  const seg = caught > 0 ? `🐴 sawbuck · ${caught} caught` : active ? `🐴 sawbuck · watching` : armed ? `🐴 sawbuck · ready` : mem ? `🐴 sawbuck · memory` : '';
261
- 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);
262
282
  }
263
283
  } catch {}
264
284
  break;
265
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
+ }
266
307
  default: die(`unknown command '${cmd}'\n\n${help}`);
267
308
  }
268
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
+ }