selftune 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +259 -0
  3. package/bin/selftune.cjs +29 -0
  4. package/cli/selftune/constants.ts +71 -0
  5. package/cli/selftune/eval/hooks-to-evals.ts +422 -0
  6. package/cli/selftune/evolution/audit.ts +44 -0
  7. package/cli/selftune/evolution/deploy-proposal.ts +244 -0
  8. package/cli/selftune/evolution/evolve.ts +406 -0
  9. package/cli/selftune/evolution/extract-patterns.ts +145 -0
  10. package/cli/selftune/evolution/propose-description.ts +146 -0
  11. package/cli/selftune/evolution/rollback.ts +242 -0
  12. package/cli/selftune/evolution/stopping-criteria.ts +69 -0
  13. package/cli/selftune/evolution/validate-proposal.ts +137 -0
  14. package/cli/selftune/grading/grade-session.ts +459 -0
  15. package/cli/selftune/hooks/prompt-log.ts +52 -0
  16. package/cli/selftune/hooks/session-stop.ts +54 -0
  17. package/cli/selftune/hooks/skill-eval.ts +73 -0
  18. package/cli/selftune/index.ts +104 -0
  19. package/cli/selftune/ingestors/codex-rollout.ts +416 -0
  20. package/cli/selftune/ingestors/codex-wrapper.ts +332 -0
  21. package/cli/selftune/ingestors/opencode-ingest.ts +565 -0
  22. package/cli/selftune/init.ts +297 -0
  23. package/cli/selftune/monitoring/watch.ts +328 -0
  24. package/cli/selftune/observability.ts +255 -0
  25. package/cli/selftune/types.ts +255 -0
  26. package/cli/selftune/utils/jsonl.ts +75 -0
  27. package/cli/selftune/utils/llm-call.ts +192 -0
  28. package/cli/selftune/utils/logging.ts +40 -0
  29. package/cli/selftune/utils/schema-validator.ts +47 -0
  30. package/cli/selftune/utils/seeded-random.ts +31 -0
  31. package/cli/selftune/utils/transcript.ts +260 -0
  32. package/package.json +29 -0
  33. package/skill/SKILL.md +120 -0
  34. package/skill/Workflows/Doctor.md +145 -0
  35. package/skill/Workflows/Evals.md +193 -0
  36. package/skill/Workflows/Evolve.md +159 -0
  37. package/skill/Workflows/Grade.md +157 -0
  38. package/skill/Workflows/Ingest.md +159 -0
  39. package/skill/Workflows/Initialize.md +125 -0
  40. package/skill/Workflows/Rollback.md +131 -0
  41. package/skill/Workflows/Watch.md +128 -0
  42. package/skill/references/grading-methodology.md +176 -0
  43. package/skill/references/invocation-taxonomy.md +144 -0
  44. package/skill/references/logs.md +168 -0
  45. package/skill/settings_snippet.json +41 -0
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Claude Code PostToolUse hook: skill-eval.ts
4
+ *
5
+ * Fires whenever Claude reads a file. If that file is a SKILL.md, this hook:
6
+ * 1. Finds the triggering user query from the transcript JSONL
7
+ * 2. Appends a usage record to ~/.claude/skill_usage_log.jsonl
8
+ *
9
+ * This builds a real-usage eval dataset over time, seeding the
10
+ * `should_trigger: true` half of trigger evals.
11
+ */
12
+
13
+ import { basename, dirname } from "node:path";
14
+ import { SKILL_LOG } from "../constants.js";
15
+ import type { PostToolUsePayload, SkillUsageRecord } from "../types.js";
16
+ import { appendJsonl } from "../utils/jsonl.js";
17
+ import { getLastUserMessage } from "../utils/transcript.js";
18
+
19
+ /**
20
+ * Extract the skill folder name from a file path ending in SKILL.md.
21
+ * Returns null if this doesn't look like a skill file.
22
+ */
23
+ export function extractSkillName(filePath: string): string | null {
24
+ if (basename(filePath).toUpperCase() !== "SKILL.MD") return null;
25
+ return basename(dirname(filePath)) || "unknown";
26
+ }
27
+
28
+ /**
29
+ * Core processing logic, exported for testability.
30
+ * Returns the record that was appended, or null if skipped.
31
+ */
32
+ export function processToolUse(
33
+ payload: PostToolUsePayload,
34
+ logPath: string = SKILL_LOG,
35
+ ): SkillUsageRecord | null {
36
+ // Only care about Read tool
37
+ if (payload.tool_name !== "Read") return null;
38
+
39
+ const rawPath = payload.tool_input?.file_path;
40
+ const filePath = typeof rawPath === "string" ? rawPath : "";
41
+ const skillName = extractSkillName(filePath);
42
+
43
+ if (skillName === null) return null;
44
+
45
+ const transcriptPath = payload.transcript_path ?? "";
46
+ const sessionId = payload.session_id ?? "unknown";
47
+
48
+ const query = getLastUserMessage(transcriptPath) ?? "(query not found)";
49
+
50
+ const record: SkillUsageRecord = {
51
+ timestamp: new Date().toISOString(),
52
+ session_id: sessionId,
53
+ skill_name: skillName,
54
+ skill_path: filePath,
55
+ query,
56
+ triggered: true,
57
+ source: "claude_code",
58
+ };
59
+
60
+ appendJsonl(logPath, record);
61
+ return record;
62
+ }
63
+
64
+ // --- stdin main (only when executed directly, not when imported) ---
65
+ if (import.meta.main) {
66
+ try {
67
+ const payload: PostToolUsePayload = JSON.parse(await Bun.stdin.text());
68
+ processToolUse(payload);
69
+ } catch {
70
+ // silent — hooks must never block Claude
71
+ }
72
+ process.exit(0);
73
+ }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * selftune CLI entry point.
4
+ *
5
+ * Usage:
6
+ * selftune init [options] — Initialize agent identity and config
7
+ * selftune evals [options] — Generate eval sets from hook logs
8
+ * selftune grade [options] — Grade a skill session
9
+ * selftune ingest-codex [options] — Ingest Codex rollout logs
10
+ * selftune ingest-opencode [options] — Ingest OpenCode sessions
11
+ * selftune wrap-codex [options] — Wrap codex exec with telemetry
12
+ * selftune evolve [options] — Evolve a skill description via failure patterns
13
+ * selftune rollback [options] — Rollback a skill to its pre-evolution state
14
+ * selftune watch [options] — Monitor post-deploy skill health
15
+ * selftune doctor — Run health checks
16
+ */
17
+
18
+ const command = process.argv[2];
19
+
20
+ if (!command || command === "--help" || command === "-h") {
21
+ console.log(`selftune — Skill observability and continuous improvement
22
+
23
+ Usage:
24
+ selftune <command> [options]
25
+
26
+ Commands:
27
+ init Initialize agent identity and config
28
+ evals Generate eval sets from hook logs
29
+ grade Grade a skill session
30
+ ingest-codex Ingest Codex rollout logs
31
+ ingest-opencode Ingest OpenCode sessions
32
+ wrap-codex Wrap codex exec with telemetry
33
+ evolve Evolve a skill description via failure patterns
34
+ rollback Rollback a skill to its pre-evolution state
35
+ watch Monitor post-deploy skill health
36
+ doctor Run health checks
37
+
38
+ Run 'selftune <command> --help' for command-specific options.`);
39
+ process.exit(0);
40
+ }
41
+
42
+ // Route to the appropriate subcommand module.
43
+ // We use dynamic imports so only the needed module is loaded.
44
+ // Each module exports a cliMain() function that the router calls explicitly,
45
+ // since import.meta.main is false for dynamically imported modules.
46
+ process.argv = [process.argv[0], process.argv[1], ...process.argv.slice(3)];
47
+
48
+ switch (command) {
49
+ case "init": {
50
+ const { cliMain } = await import("./init.js");
51
+ await cliMain();
52
+ break;
53
+ }
54
+ case "evals": {
55
+ const { cliMain } = await import("./eval/hooks-to-evals.js");
56
+ cliMain();
57
+ break;
58
+ }
59
+ case "grade": {
60
+ const { cliMain } = await import("./grading/grade-session.js");
61
+ await cliMain();
62
+ break;
63
+ }
64
+ case "ingest-codex": {
65
+ const { cliMain } = await import("./ingestors/codex-rollout.js");
66
+ cliMain();
67
+ break;
68
+ }
69
+ case "ingest-opencode": {
70
+ const { cliMain } = await import("./ingestors/opencode-ingest.js");
71
+ cliMain();
72
+ break;
73
+ }
74
+ case "wrap-codex": {
75
+ const { cliMain } = await import("./ingestors/codex-wrapper.js");
76
+ await cliMain();
77
+ break;
78
+ }
79
+ case "evolve": {
80
+ const { cliMain } = await import("./evolution/evolve.js");
81
+ await cliMain();
82
+ break;
83
+ }
84
+ case "rollback": {
85
+ const { cliMain } = await import("./evolution/rollback.js");
86
+ await cliMain();
87
+ break;
88
+ }
89
+ case "watch": {
90
+ const { cliMain } = await import("./monitoring/watch.js");
91
+ await cliMain();
92
+ break;
93
+ }
94
+ case "doctor": {
95
+ const { doctor } = await import("./observability.js");
96
+ const result = doctor();
97
+ console.log(JSON.stringify(result, null, 2));
98
+ process.exit(result.healthy ? 0 : 1);
99
+ break;
100
+ }
101
+ default:
102
+ console.error(`Unknown command: ${command}\nRun 'selftune --help' for available commands.`);
103
+ process.exit(1);
104
+ }
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Codex rollout ingestor: codex-rollout.ts
4
+ *
5
+ * Retroactively ingests Codex's auto-written rollout logs into our shared
6
+ * skill eval log format.
7
+ *
8
+ * Codex CLI saves every session to:
9
+ * $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<thread_id>.jsonl
10
+ *
11
+ * This script scans those files and populates:
12
+ * ~/.claude/all_queries_log.jsonl
13
+ * ~/.claude/session_telemetry_log.jsonl
14
+ * ~/.claude/skill_usage_log.jsonl
15
+ *
16
+ * Usage:
17
+ * bun codex-rollout.ts
18
+ * bun codex-rollout.ts --since 2026-01-01
19
+ * bun codex-rollout.ts --codex-home /custom/path
20
+ * bun codex-rollout.ts --dry-run
21
+ * bun codex-rollout.ts --force
22
+ */
23
+
24
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { basename, join } from "node:path";
27
+ import { parseArgs } from "node:util";
28
+ import { QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "../constants.js";
29
+ import type { QueryLogRecord, SessionTelemetryRecord, SkillUsageRecord } from "../types.js";
30
+ import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
31
+
32
+ const MARKER_FILE = join(homedir(), ".claude", "codex_ingested_rollouts.json");
33
+
34
+ const DEFAULT_CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
35
+
36
+ const CODEX_SKILLS_DIRS = [
37
+ join(process.cwd(), ".codex", "skills"),
38
+ join(homedir(), ".codex", "skills"),
39
+ ];
40
+
41
+ /** Return skill names from Codex skill directories. */
42
+ export function findSkillNames(dirs: string[] = CODEX_SKILLS_DIRS): Set<string> {
43
+ const names = new Set<string>();
44
+ for (const dir of dirs) {
45
+ if (!existsSync(dir)) continue;
46
+ for (const entry of readdirSync(dir)) {
47
+ const skillDir = join(dir, entry);
48
+ try {
49
+ if (statSync(skillDir).isDirectory() && existsSync(join(skillDir, "SKILL.md"))) {
50
+ names.add(entry);
51
+ }
52
+ } catch {
53
+ // skip entries that can't be stat'd (broken symlinks, permission errors, etc.)
54
+ }
55
+ }
56
+ }
57
+ return names;
58
+ }
59
+
60
+ /**
61
+ * Find all rollout-*.jsonl files under codexHome/sessions/YYYY/MM/DD/.
62
+ * If `since` is given, only return files from that date onward.
63
+ */
64
+ export function findRolloutFiles(codexHome: string, since?: Date): string[] {
65
+ const sessionsDir = join(codexHome, "sessions");
66
+ if (!existsSync(sessionsDir)) return [];
67
+
68
+ const files: string[] = [];
69
+
70
+ for (const yearEntry of readdirSync(sessionsDir).sort()) {
71
+ const yearDir = join(sessionsDir, yearEntry);
72
+ try {
73
+ if (!statSync(yearDir).isDirectory()) continue;
74
+ } catch {
75
+ continue;
76
+ }
77
+ const year = Number.parseInt(yearEntry, 10);
78
+ if (Number.isNaN(year)) continue;
79
+
80
+ for (const monthEntry of readdirSync(yearDir).sort()) {
81
+ const monthDir = join(yearDir, monthEntry);
82
+ try {
83
+ if (!statSync(monthDir).isDirectory()) continue;
84
+ } catch {
85
+ continue;
86
+ }
87
+ const month = Number.parseInt(monthEntry, 10);
88
+ if (Number.isNaN(month)) continue;
89
+
90
+ for (const dayEntry of readdirSync(monthDir).sort()) {
91
+ const dayDir = join(monthDir, dayEntry);
92
+ try {
93
+ if (!statSync(dayDir).isDirectory()) continue;
94
+ } catch {
95
+ continue;
96
+ }
97
+ const day = Number.parseInt(dayEntry, 10);
98
+ if (Number.isNaN(day)) continue;
99
+
100
+ if (since) {
101
+ const fileDate = new Date(year, month - 1, day);
102
+ if (fileDate < since) continue;
103
+ }
104
+
105
+ for (const file of readdirSync(dayDir).sort()) {
106
+ if (file.startsWith("rollout-") && file.endsWith(".jsonl")) {
107
+ files.push(join(dayDir, file));
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ return files;
115
+ }
116
+
117
+ export interface ParsedRollout {
118
+ timestamp: string;
119
+ session_id: string;
120
+ source: string;
121
+ rollout_path: string;
122
+ query: string;
123
+ tool_calls: Record<string, number>;
124
+ total_tool_calls: number;
125
+ bash_commands: string[];
126
+ skills_triggered: string[];
127
+ assistant_turns: number;
128
+ errors_encountered: number;
129
+ input_tokens: number;
130
+ output_tokens: number;
131
+ transcript_chars: number;
132
+ cwd: string;
133
+ transcript_path: string;
134
+ last_user_query: string;
135
+ }
136
+
137
+ /**
138
+ * Parse a Codex rollout JSONL file.
139
+ * Returns parsed data or null if the file is empty/unparseable.
140
+ */
141
+ export function parseRolloutFile(path: string, skillNames: Set<string>): ParsedRollout | null {
142
+ let content: string;
143
+ try {
144
+ content = readFileSync(path, "utf-8");
145
+ } catch {
146
+ return null;
147
+ }
148
+
149
+ const lines = content
150
+ .split("\n")
151
+ .map((l) => l.trim())
152
+ .filter((l) => l.length > 0);
153
+
154
+ if (lines.length === 0) return null;
155
+
156
+ const threadId = basename(path, ".jsonl").replace("rollout-", "");
157
+ let prompt = "";
158
+ const toolCalls: Record<string, number> = {};
159
+ const bashCommands: string[] = [];
160
+ const skillsTriggered: string[] = [];
161
+ let errors = 0;
162
+ let turns = 0;
163
+ let inputTokens = 0;
164
+ let outputTokens = 0;
165
+
166
+ for (const line of lines) {
167
+ let event: Record<string, unknown>;
168
+ try {
169
+ event = JSON.parse(line);
170
+ } catch {
171
+ continue;
172
+ }
173
+
174
+ const etype = (event.type as string) ?? "";
175
+
176
+ if (etype === "turn.started") {
177
+ turns += 1;
178
+ } else if (etype === "turn.completed") {
179
+ const usage = (event.usage as Record<string, number>) ?? {};
180
+ inputTokens += usage.input_tokens ?? 0;
181
+ outputTokens += usage.output_tokens ?? 0;
182
+ if (!prompt) {
183
+ prompt = (event.user_message as string) ?? "";
184
+ }
185
+ } else if (etype === "turn.failed") {
186
+ errors += 1;
187
+ } else if (etype === "item.completed" || etype === "item.started" || etype === "item.updated") {
188
+ const item = (event.item as Record<string, unknown>) ?? {};
189
+ const itemType = (item.item_type as string) ?? (item.type as string) ?? "";
190
+
191
+ if (etype === "item.completed") {
192
+ if (itemType === "command_execution") {
193
+ toolCalls.command_execution = (toolCalls.command_execution ?? 0) + 1;
194
+ const cmd = ((item.command as string) ?? "").trim();
195
+ if (cmd) bashCommands.push(cmd);
196
+ if ((item.exit_code as number) !== 0 && item.exit_code !== undefined) {
197
+ errors += 1;
198
+ }
199
+ } else if (itemType === "file_change") {
200
+ toolCalls.file_change = (toolCalls.file_change ?? 0) + 1;
201
+ } else if (itemType === "mcp_tool_call") {
202
+ toolCalls.mcp_tool_call = (toolCalls.mcp_tool_call ?? 0) + 1;
203
+ } else if (itemType === "web_search") {
204
+ toolCalls.web_search = (toolCalls.web_search ?? 0) + 1;
205
+ } else if (itemType === "reasoning") {
206
+ toolCalls.reasoning = (toolCalls.reasoning ?? 0) + 1;
207
+ }
208
+ }
209
+
210
+ // Detect skill names in text content on completed events
211
+ const textContent = ((item.text as string) ?? "") + ((item.command as string) ?? "");
212
+ for (const skillName of skillNames) {
213
+ if (
214
+ textContent.includes(skillName) &&
215
+ !skillsTriggered.includes(skillName) &&
216
+ etype === "item.completed"
217
+ ) {
218
+ skillsTriggered.push(skillName);
219
+ }
220
+ }
221
+ } else if (etype === "error") {
222
+ errors += 1;
223
+ }
224
+
225
+ // Some rollout formats embed the original prompt
226
+ if (!prompt && (event.prompt as string)) {
227
+ prompt = event.prompt as string;
228
+ }
229
+ }
230
+
231
+ // Infer file date from path structure: .../YYYY/MM/DD/rollout-*.jsonl
232
+ let fileDate: string;
233
+ const parts = path.split("/");
234
+ try {
235
+ const dayStr = parts[parts.length - 2];
236
+ const monthStr = parts[parts.length - 3];
237
+ const yearStr = parts[parts.length - 4];
238
+ const year = Number.parseInt(yearStr, 10);
239
+ const month = Number.parseInt(monthStr, 10);
240
+ const day = Number.parseInt(dayStr, 10);
241
+ if (!Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
242
+ fileDate = new Date(Date.UTC(year, month - 1, day)).toISOString();
243
+ } else {
244
+ fileDate = new Date().toISOString();
245
+ }
246
+ } catch {
247
+ fileDate = new Date().toISOString();
248
+ }
249
+
250
+ return {
251
+ timestamp: fileDate,
252
+ session_id: threadId,
253
+ source: "codex_rollout",
254
+ rollout_path: path,
255
+ query: prompt,
256
+ tool_calls: toolCalls,
257
+ total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
258
+ bash_commands: bashCommands,
259
+ skills_triggered: skillsTriggered,
260
+ assistant_turns: turns,
261
+ errors_encountered: errors,
262
+ input_tokens: inputTokens,
263
+ output_tokens: outputTokens,
264
+ transcript_chars: lines.reduce((sum, l) => sum + l.length, 0),
265
+ cwd: "",
266
+ transcript_path: path,
267
+ last_user_query: prompt,
268
+ };
269
+ }
270
+
271
+ /** Write parsed session data to shared logs. */
272
+ export function ingestFile(
273
+ parsed: ParsedRollout,
274
+ dryRun = false,
275
+ queryLogPath: string = QUERY_LOG,
276
+ telemetryLogPath: string = TELEMETRY_LOG,
277
+ skillLogPath: string = SKILL_LOG,
278
+ ): boolean {
279
+ const { query: prompt, session_id: sessionId, skills_triggered: skills } = parsed;
280
+
281
+ if (dryRun) {
282
+ console.log(
283
+ ` [DRY RUN] Would ingest: session=${sessionId.slice(0, 12)}... ` +
284
+ `turns=${parsed.assistant_turns} commands=${parsed.bash_commands.length} skills=${JSON.stringify(skills)}`,
285
+ );
286
+ if (prompt) console.log(` query: ${prompt.slice(0, 80)}`);
287
+ return true;
288
+ }
289
+
290
+ // Write to all_queries_log if we have a prompt
291
+ if (prompt && prompt.length >= 4) {
292
+ const queryRecord: QueryLogRecord = {
293
+ timestamp: parsed.timestamp,
294
+ session_id: sessionId,
295
+ query: prompt,
296
+ source: "codex_rollout",
297
+ };
298
+ appendJsonl(queryLogPath, queryRecord, "all_queries");
299
+ }
300
+
301
+ // Write telemetry — explicitly select SessionTelemetryRecord fields
302
+ const telemetry: SessionTelemetryRecord = {
303
+ timestamp: parsed.timestamp,
304
+ session_id: sessionId,
305
+ cwd: parsed.cwd,
306
+ transcript_path: parsed.transcript_path,
307
+ tool_calls: parsed.tool_calls,
308
+ total_tool_calls: parsed.total_tool_calls,
309
+ bash_commands: parsed.bash_commands,
310
+ skills_triggered: skills,
311
+ assistant_turns: parsed.assistant_turns,
312
+ errors_encountered: parsed.errors_encountered,
313
+ transcript_chars: parsed.transcript_chars,
314
+ last_user_query: parsed.last_user_query,
315
+ source: parsed.source,
316
+ input_tokens: parsed.input_tokens,
317
+ output_tokens: parsed.output_tokens,
318
+ rollout_path: parsed.rollout_path,
319
+ };
320
+ appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
321
+
322
+ // Write skill triggers
323
+ for (const skillName of skills) {
324
+ const skillRecord: SkillUsageRecord = {
325
+ timestamp: parsed.timestamp,
326
+ session_id: sessionId,
327
+ skill_name: skillName,
328
+ skill_path: `(codex:${skillName})`,
329
+ query: prompt,
330
+ triggered: true,
331
+ source: "codex_rollout",
332
+ };
333
+ appendJsonl(skillLogPath, skillRecord, "skill_usage");
334
+ }
335
+
336
+ return true;
337
+ }
338
+
339
+ // --- CLI main ---
340
+ export function cliMain(): void {
341
+ const { values } = parseArgs({
342
+ options: {
343
+ "codex-home": { type: "string", default: DEFAULT_CODEX_HOME },
344
+ since: { type: "string" },
345
+ "dry-run": { type: "boolean", default: false },
346
+ force: { type: "boolean", default: false },
347
+ verbose: { type: "boolean", short: "v", default: false },
348
+ },
349
+ strict: true,
350
+ });
351
+
352
+ const codexHome = values["codex-home"] ?? DEFAULT_CODEX_HOME;
353
+ let since: Date | undefined;
354
+ if (values.since) {
355
+ since = new Date(values.since);
356
+ if (Number.isNaN(since.getTime())) {
357
+ console.error(
358
+ `Error: Invalid --since date: "${values.since}". Use a valid date format (e.g., 2026-01-01).`,
359
+ );
360
+ process.exit(1);
361
+ }
362
+ }
363
+
364
+ const rolloutFiles = findRolloutFiles(codexHome, since);
365
+ if (rolloutFiles.length === 0) {
366
+ console.log(`No rollout files found under ${codexHome}/sessions/`);
367
+ console.log("Make sure CODEX_HOME is correct and you've run some `codex exec` sessions.");
368
+ process.exit(0);
369
+ }
370
+
371
+ const alreadyIngested = values.force ? new Set<string>() : loadMarker(MARKER_FILE);
372
+ const skillNames = findSkillNames();
373
+ const newIngested = new Set<string>();
374
+
375
+ const pending = rolloutFiles.filter((f) => !alreadyIngested.has(f));
376
+ console.log(`Found ${rolloutFiles.length} rollout files, ${pending.length} not yet ingested.`);
377
+
378
+ if (since) {
379
+ console.log(` Filtering to sessions from ${values.since} onward.`);
380
+ }
381
+
382
+ let ingestedCount = 0;
383
+ let skippedCount = 0;
384
+
385
+ for (const rolloutFile of pending) {
386
+ const parsed = parseRolloutFile(rolloutFile, skillNames);
387
+ if (parsed === null) {
388
+ if (values.verbose) {
389
+ console.log(` SKIP (empty/unparseable): ${basename(rolloutFile)}`);
390
+ }
391
+ skippedCount += 1;
392
+ continue;
393
+ }
394
+
395
+ if (values.verbose || values["dry-run"]) {
396
+ console.log(` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${basename(rolloutFile)}`);
397
+ }
398
+
399
+ ingestFile(parsed, values["dry-run"]);
400
+ newIngested.add(rolloutFile);
401
+ ingestedCount += 1;
402
+ }
403
+
404
+ if (!values["dry-run"]) {
405
+ saveMarker(MARKER_FILE, new Set([...alreadyIngested, ...newIngested]));
406
+ }
407
+
408
+ console.log(`\nDone. Ingested ${ingestedCount} sessions, skipped ${skippedCount}.`);
409
+ if (newIngested.size > 0 && !values["dry-run"]) {
410
+ console.log(`Marker updated: ${MARKER_FILE}`);
411
+ }
412
+ }
413
+
414
+ if (import.meta.main) {
415
+ cliMain();
416
+ }