selftune 0.1.2 → 0.2.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 (89) hide show
  1. package/.claude/agents/diagnosis-analyst.md +146 -0
  2. package/.claude/agents/evolution-reviewer.md +167 -0
  3. package/.claude/agents/integration-guide.md +200 -0
  4. package/.claude/agents/pattern-analyst.md +147 -0
  5. package/CHANGELOG.md +38 -1
  6. package/README.md +96 -256
  7. package/assets/BeforeAfter.gif +0 -0
  8. package/assets/FeedbackLoop.gif +0 -0
  9. package/assets/logo.svg +9 -0
  10. package/assets/skill-health-badge.svg +20 -0
  11. package/cli/selftune/activation-rules.ts +171 -0
  12. package/cli/selftune/badge/badge-data.ts +108 -0
  13. package/cli/selftune/badge/badge-svg.ts +212 -0
  14. package/cli/selftune/badge/badge.ts +103 -0
  15. package/cli/selftune/constants.ts +75 -1
  16. package/cli/selftune/contribute/bundle.ts +314 -0
  17. package/cli/selftune/contribute/contribute.ts +214 -0
  18. package/cli/selftune/contribute/sanitize.ts +162 -0
  19. package/cli/selftune/cron/setup.ts +266 -0
  20. package/cli/selftune/dashboard-server.ts +582 -0
  21. package/cli/selftune/dashboard.ts +31 -12
  22. package/cli/selftune/eval/baseline.ts +247 -0
  23. package/cli/selftune/eval/composability.ts +117 -0
  24. package/cli/selftune/eval/generate-unit-tests.ts +143 -0
  25. package/cli/selftune/eval/hooks-to-evals.ts +68 -2
  26. package/cli/selftune/eval/import-skillsbench.ts +221 -0
  27. package/cli/selftune/eval/synthetic-evals.ts +172 -0
  28. package/cli/selftune/eval/unit-test-cli.ts +152 -0
  29. package/cli/selftune/eval/unit-test.ts +196 -0
  30. package/cli/selftune/evolution/deploy-proposal.ts +142 -1
  31. package/cli/selftune/evolution/evolve-body.ts +492 -0
  32. package/cli/selftune/evolution/evolve.ts +479 -104
  33. package/cli/selftune/evolution/extract-patterns.ts +32 -1
  34. package/cli/selftune/evolution/pareto.ts +314 -0
  35. package/cli/selftune/evolution/propose-body.ts +171 -0
  36. package/cli/selftune/evolution/propose-description.ts +100 -2
  37. package/cli/selftune/evolution/propose-routing.ts +166 -0
  38. package/cli/selftune/evolution/refine-body.ts +141 -0
  39. package/cli/selftune/evolution/rollback.ts +20 -3
  40. package/cli/selftune/evolution/validate-body.ts +254 -0
  41. package/cli/selftune/evolution/validate-proposal.ts +257 -35
  42. package/cli/selftune/evolution/validate-routing.ts +177 -0
  43. package/cli/selftune/grading/grade-session.ts +145 -19
  44. package/cli/selftune/grading/pre-gates.ts +104 -0
  45. package/cli/selftune/hooks/auto-activate.ts +185 -0
  46. package/cli/selftune/hooks/evolution-guard.ts +165 -0
  47. package/cli/selftune/hooks/skill-change-guard.ts +112 -0
  48. package/cli/selftune/index.ts +88 -0
  49. package/cli/selftune/ingestors/claude-replay.ts +351 -0
  50. package/cli/selftune/ingestors/codex-rollout.ts +1 -1
  51. package/cli/selftune/ingestors/openclaw-ingest.ts +440 -0
  52. package/cli/selftune/ingestors/opencode-ingest.ts +2 -2
  53. package/cli/selftune/init.ts +168 -5
  54. package/cli/selftune/last.ts +2 -2
  55. package/cli/selftune/memory/writer.ts +447 -0
  56. package/cli/selftune/monitoring/watch.ts +25 -2
  57. package/cli/selftune/status.ts +18 -15
  58. package/cli/selftune/types.ts +377 -5
  59. package/cli/selftune/utils/frontmatter.ts +217 -0
  60. package/cli/selftune/utils/llm-call.ts +29 -3
  61. package/cli/selftune/utils/transcript.ts +35 -0
  62. package/cli/selftune/utils/trigger-check.ts +89 -0
  63. package/cli/selftune/utils/tui.ts +156 -0
  64. package/dashboard/index.html +585 -19
  65. package/package.json +17 -6
  66. package/skill/SKILL.md +127 -10
  67. package/skill/Workflows/AutoActivation.md +144 -0
  68. package/skill/Workflows/Badge.md +118 -0
  69. package/skill/Workflows/Baseline.md +121 -0
  70. package/skill/Workflows/Composability.md +100 -0
  71. package/skill/Workflows/Contribute.md +91 -0
  72. package/skill/Workflows/Cron.md +155 -0
  73. package/skill/Workflows/Dashboard.md +203 -0
  74. package/skill/Workflows/Doctor.md +37 -1
  75. package/skill/Workflows/Evals.md +73 -5
  76. package/skill/Workflows/EvolutionMemory.md +152 -0
  77. package/skill/Workflows/Evolve.md +111 -6
  78. package/skill/Workflows/EvolveBody.md +159 -0
  79. package/skill/Workflows/ImportSkillsBench.md +111 -0
  80. package/skill/Workflows/Ingest.md +129 -15
  81. package/skill/Workflows/Initialize.md +58 -3
  82. package/skill/Workflows/Replay.md +70 -0
  83. package/skill/Workflows/Rollback.md +20 -1
  84. package/skill/Workflows/UnitTest.md +138 -0
  85. package/skill/Workflows/Watch.md +22 -0
  86. package/skill/settings_snippet.json +23 -0
  87. package/templates/activation-rules-default.json +27 -0
  88. package/templates/multi-skill-settings.json +64 -0
  89. package/templates/single-skill-settings.json +58 -0
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Claude Code PreToolUse hook: skill-change-guard.ts
4
+ *
5
+ * Fires before Write/Edit tool calls. If the target is a SKILL.md file,
6
+ * outputs a suggestion to run `selftune watch --skill <name>` to monitor
7
+ * the impact of the change.
8
+ *
9
+ * This is advisory only — exit code is always 0, never blocking.
10
+ * Uses session state to avoid repeating suggestions for the same skill.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { basename, dirname } from "node:path";
15
+ import { SESSION_STATE_DIR } from "../constants.js";
16
+ import type { PreToolUsePayload } from "../types.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Detection helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Check if a tool call is a Write or Edit targeting a SKILL.md file. */
23
+ export function isSkillMdWrite(toolName: string, filePath: string): boolean {
24
+ if (toolName !== "Write" && toolName !== "Edit") return false;
25
+ return basename(filePath).toUpperCase() === "SKILL.MD";
26
+ }
27
+
28
+ /** Extract the skill folder name from a path ending in SKILL.md. */
29
+ export function extractSkillNameFromPath(filePath: string): string {
30
+ return basename(dirname(filePath)) || "unknown";
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Session state (minimal — just tracks which skills we've already warned about)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface GuardState {
38
+ session_id: string;
39
+ warned_skills: string[];
40
+ }
41
+
42
+ function loadGuardState(path: string, sessionId: string): GuardState {
43
+ if (!existsSync(path)) {
44
+ return { session_id: sessionId, warned_skills: [] };
45
+ }
46
+ try {
47
+ const data = JSON.parse(readFileSync(path, "utf-8")) as GuardState;
48
+ if (data.session_id === sessionId && Array.isArray(data.warned_skills)) {
49
+ return data;
50
+ }
51
+ } catch {
52
+ // corrupt — start fresh
53
+ }
54
+ return { session_id: sessionId, warned_skills: [] };
55
+ }
56
+
57
+ function saveGuardState(path: string, state: GuardState): void {
58
+ const dir = dirname(path);
59
+ if (!existsSync(dir)) {
60
+ mkdirSync(dir, { recursive: true });
61
+ }
62
+ writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Core processing logic
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Process a PreToolUse payload and return a suggestion string if the tool
71
+ * call is writing to a SKILL.md file that hasn't been warned about yet.
72
+ */
73
+ export function processPreToolUse(payload: PreToolUsePayload, statePath: string): string | null {
74
+ const filePath =
75
+ typeof payload.tool_input?.file_path === "string" ? payload.tool_input.file_path : "";
76
+
77
+ if (!isSkillMdWrite(payload.tool_name, filePath)) return null;
78
+
79
+ const skillName = extractSkillNameFromPath(filePath);
80
+ const sessionId = payload.session_id ?? "unknown";
81
+
82
+ // Check if we've already warned about this skill in this session
83
+ const state = loadGuardState(statePath, sessionId);
84
+ if (state.warned_skills.includes(skillName)) return null;
85
+
86
+ // Record that we warned about this skill
87
+ state.warned_skills.push(skillName);
88
+ saveGuardState(statePath, state);
89
+
90
+ return `Run \`selftune watch --skill ${skillName}\` to monitor the impact of this SKILL.md change.`;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // stdin main (only when executed directly, not when imported)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ if (import.meta.main) {
98
+ try {
99
+ const payload: PreToolUsePayload = JSON.parse(await Bun.stdin.text());
100
+ const sessionId = payload.session_id ?? "unknown";
101
+ const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
102
+ const statePath = `${SESSION_STATE_DIR}/guard-state-${safe}.json`;
103
+
104
+ const suggestion = processPreToolUse(payload, statePath);
105
+ if (suggestion) {
106
+ process.stderr.write(`[selftune] 💡 Suggestion: ${suggestion}\n`);
107
+ }
108
+ } catch {
109
+ // silent — hooks must never block Claude
110
+ }
111
+ process.exit(0);
112
+ }
@@ -8,14 +8,23 @@
8
8
  * selftune grade [options] — Grade a skill session
9
9
  * selftune ingest-codex [options] — Ingest Codex rollout logs
10
10
  * selftune ingest-opencode [options] — Ingest OpenCode sessions
11
+ * selftune ingest-openclaw [options] — Ingest OpenClaw sessions
11
12
  * selftune wrap-codex [options] — Wrap codex exec with telemetry
13
+ * selftune replay [options] — Replay Claude Code transcripts into logs
14
+ * selftune contribute [options] — Export anonymized skill data for community
12
15
  * selftune evolve [options] — Evolve a skill description via failure patterns
16
+ * selftune evolve-body [options] — Evolve a skill body or routing table
13
17
  * selftune rollback [options] — Rollback a skill to its pre-evolution state
14
18
  * selftune watch [options] — Monitor post-deploy skill health
15
19
  * selftune doctor — Run health checks
16
20
  * selftune status — Show skill health summary
17
21
  * selftune last — Show last session details
18
22
  * selftune dashboard [options] — Open visual data dashboard
23
+ * selftune cron [options] — Manage OpenClaw cron jobs (setup, list, remove)
24
+ * selftune baseline [options] — Measure skill value vs. no-skill baseline
25
+ * selftune composability [options] — Analyze skill co-occurrence conflicts
26
+ * selftune unit-test [options] — Run or generate skill unit tests
27
+ * selftune import-skillsbench [options] — Import SkillsBench task corpus as eval entries
19
28
  */
20
29
 
21
30
  const command = process.argv[2];
@@ -32,14 +41,24 @@ Commands:
32
41
  grade Grade a skill session
33
42
  ingest-codex Ingest Codex rollout logs
34
43
  ingest-opencode Ingest OpenCode sessions
44
+ ingest-openclaw Ingest OpenClaw sessions
35
45
  wrap-codex Wrap codex exec with telemetry
46
+ replay Replay Claude Code transcripts into logs
47
+ contribute Export anonymized skill data for community
36
48
  evolve Evolve a skill description via failure patterns
49
+ evolve-body Evolve a skill body or routing table
37
50
  rollback Rollback a skill to its pre-evolution state
38
51
  watch Monitor post-deploy skill health
39
52
  doctor Run health checks
40
53
  status Show skill health summary
41
54
  last Show last session details
42
55
  dashboard Open visual data dashboard
56
+ cron Manage OpenClaw cron jobs (setup, list, remove)
57
+ badge Generate skill health badges for READMEs
58
+ baseline Measure skill value vs. no-skill baseline
59
+ composability Analyze skill co-occurrence conflicts
60
+ unit-test Run or generate skill unit tests
61
+ import-skillsbench Import SkillsBench task corpus as eval entries
43
62
 
44
63
  Run 'selftune <command> --help' for command-specific options.`);
45
64
  process.exit(0);
@@ -77,16 +96,41 @@ switch (command) {
77
96
  cliMain();
78
97
  break;
79
98
  }
99
+ case "ingest-openclaw": {
100
+ const { cliMain } = await import("./ingestors/openclaw-ingest.js");
101
+ cliMain();
102
+ break;
103
+ }
80
104
  case "wrap-codex": {
81
105
  const { cliMain } = await import("./ingestors/codex-wrapper.js");
82
106
  await cliMain();
83
107
  break;
84
108
  }
109
+ case "replay": {
110
+ const { cliMain } = await import("./ingestors/claude-replay.js");
111
+ cliMain();
112
+ break;
113
+ }
114
+ case "contribute": {
115
+ const { cliMain } = await import("./contribute/contribute.js");
116
+ await cliMain();
117
+ break;
118
+ }
85
119
  case "evolve": {
86
120
  const { cliMain } = await import("./evolution/evolve.js");
87
121
  await cliMain();
88
122
  break;
89
123
  }
124
+ case "evolve-body": {
125
+ const { cliMain } = await import("./evolution/evolve-body.js");
126
+ await cliMain();
127
+ break;
128
+ }
129
+ case "baseline": {
130
+ const { cliMain } = await import("./eval/baseline.js");
131
+ await cliMain();
132
+ break;
133
+ }
90
134
  case "rollback": {
91
135
  const { cliMain } = await import("./evolution/rollback.js");
92
136
  await cliMain();
@@ -116,9 +160,53 @@ switch (command) {
116
160
  }
117
161
  case "dashboard": {
118
162
  const { cliMain } = await import("./dashboard.js");
163
+ await cliMain();
164
+ break;
165
+ }
166
+ case "cron": {
167
+ const { cliMain } = await import("./cron/setup.js");
168
+ await cliMain();
169
+ break;
170
+ }
171
+ case "badge": {
172
+ const { cliMain } = await import("./badge/badge.js");
173
+ cliMain();
174
+ break;
175
+ }
176
+ case "unit-test": {
177
+ const { cliMain } = await import("./eval/unit-test-cli.js");
178
+ await cliMain();
179
+ break;
180
+ }
181
+ case "import-skillsbench": {
182
+ const { cliMain } = await import("./eval/import-skillsbench.js");
119
183
  cliMain();
120
184
  break;
121
185
  }
186
+ case "composability": {
187
+ const { parseArgs } = await import("node:util");
188
+ const { readJsonl } = await import("./utils/jsonl.js");
189
+ const { TELEMETRY_LOG } = await import("./constants.js");
190
+ const { analyzeComposability } = await import("./eval/composability.js");
191
+ const { values } = parseArgs({
192
+ options: {
193
+ skill: { type: "string" },
194
+ window: { type: "string" },
195
+ "telemetry-log": { type: "string" },
196
+ },
197
+ strict: true,
198
+ });
199
+ if (!values.skill) {
200
+ console.error("[ERROR] --skill <name> is required.");
201
+ process.exit(1);
202
+ }
203
+ const logPath = values["telemetry-log"] ?? TELEMETRY_LOG;
204
+ const telemetry = readJsonl(logPath);
205
+ const windowSize = values.window ? Number.parseInt(values.window, 10) : undefined;
206
+ const report = analyzeComposability(values.skill, telemetry, windowSize);
207
+ console.log(JSON.stringify(report, null, 2));
208
+ break;
209
+ }
122
210
  default:
123
211
  console.error(`Unknown command: ${command}\nRun 'selftune --help' for available commands.`);
124
212
  process.exit(1);
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Claude Code transcript ingestor: claude-replay.ts
4
+ *
5
+ * Retroactively ingests Claude Code session transcripts into our shared
6
+ * skill eval log format.
7
+ *
8
+ * Claude Code saves transcripts to:
9
+ * ~/.claude/projects/<hash>/<session-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 claude-replay.ts
18
+ * bun claude-replay.ts --since 2026-01-01
19
+ * bun claude-replay.ts --projects-dir /custom/path
20
+ * bun claude-replay.ts --dry-run
21
+ * bun claude-replay.ts --force
22
+ */
23
+
24
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25
+ import { basename, join } from "node:path";
26
+ import { parseArgs } from "node:util";
27
+ import {
28
+ CLAUDE_CODE_MARKER,
29
+ CLAUDE_CODE_PROJECTS_DIR,
30
+ QUERY_LOG,
31
+ SKILL_LOG,
32
+ SKIP_PREFIXES,
33
+ TELEMETRY_LOG,
34
+ } from "../constants.js";
35
+ import type {
36
+ QueryLogRecord,
37
+ SessionTelemetryRecord,
38
+ SkillUsageRecord,
39
+ TranscriptMetrics,
40
+ } from "../types.js";
41
+ import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
42
+ import { parseTranscript } from "../utils/transcript.js";
43
+
44
+ export interface ParsedSession {
45
+ transcript_path: string;
46
+ session_id: string;
47
+ timestamp: string;
48
+ metrics: TranscriptMetrics;
49
+ user_queries: Array<{ query: string; timestamp: string }>;
50
+ }
51
+
52
+ /**
53
+ * Find all .jsonl transcript files under projectsDir/<hash>/<session>.jsonl.
54
+ * If `since` is given, only return files with mtime >= since.
55
+ */
56
+ export function findTranscriptFiles(projectsDir: string, since?: Date): string[] {
57
+ if (!existsSync(projectsDir)) return [];
58
+
59
+ const files: string[] = [];
60
+
61
+ let hashDirs: string[];
62
+ try {
63
+ hashDirs = readdirSync(projectsDir).sort();
64
+ } catch {
65
+ return [];
66
+ }
67
+
68
+ for (const hashEntry of hashDirs) {
69
+ const hashDir = join(projectsDir, hashEntry);
70
+ try {
71
+ if (!statSync(hashDir).isDirectory()) continue;
72
+ } catch {
73
+ continue;
74
+ }
75
+
76
+ let sessionFiles: string[];
77
+ try {
78
+ sessionFiles = readdirSync(hashDir).sort();
79
+ } catch {
80
+ continue;
81
+ }
82
+
83
+ for (const file of sessionFiles) {
84
+ if (!file.endsWith(".jsonl")) continue;
85
+
86
+ const filePath = join(hashDir, file);
87
+ if (since) {
88
+ try {
89
+ const mtime = statSync(filePath).mtime;
90
+ if (mtime < since) continue;
91
+ } catch {
92
+ continue;
93
+ }
94
+ }
95
+
96
+ files.push(filePath);
97
+ }
98
+ }
99
+
100
+ return files.sort();
101
+ }
102
+
103
+ /**
104
+ * Extract all user queries from a Claude Code transcript JSONL.
105
+ *
106
+ * Handles two transcript variants:
107
+ * Variant A: {"type": "user", "message": {"role": "user", "content": [...]}}
108
+ * Variant B: {"role": "user", "content": "..."}
109
+ *
110
+ * Filters out messages matching SKIP_PREFIXES and queries < 4 chars.
111
+ */
112
+ export function extractAllUserQueries(
113
+ transcriptPath: string,
114
+ ): Array<{ query: string; timestamp: string }> {
115
+ if (!existsSync(transcriptPath)) return [];
116
+
117
+ let content: string;
118
+ try {
119
+ content = readFileSync(transcriptPath, "utf-8");
120
+ } catch {
121
+ return [];
122
+ }
123
+
124
+ const results: Array<{ query: string; timestamp: string }> = [];
125
+
126
+ for (const raw of content.split("\n")) {
127
+ const line = raw.trim();
128
+ if (!line) continue;
129
+
130
+ let entry: Record<string, unknown>;
131
+ try {
132
+ entry = JSON.parse(line);
133
+ } catch {
134
+ continue;
135
+ }
136
+
137
+ // Normalise: unwrap nested message if present
138
+ const msg = (entry.message as Record<string, unknown>) ?? entry;
139
+ const role = (msg.role as string) ?? (entry.role as string) ?? "";
140
+
141
+ if (role !== "user") continue;
142
+
143
+ const entryContent = msg.content ?? entry.content ?? "";
144
+ let text = "";
145
+
146
+ if (typeof entryContent === "string") {
147
+ text = entryContent.trim();
148
+ } else if (Array.isArray(entryContent)) {
149
+ const texts = entryContent
150
+ .filter(
151
+ (p): p is Record<string, unknown> =>
152
+ typeof p === "object" && p !== null && (p as Record<string, unknown>).type === "text",
153
+ )
154
+ .map((p) => (p.text as string) ?? "")
155
+ .filter(Boolean);
156
+ text = texts.join(" ").trim();
157
+ }
158
+
159
+ if (!text) continue;
160
+
161
+ // Apply SKIP_PREFIXES filter
162
+ const shouldSkip = SKIP_PREFIXES.some((prefix) => text.startsWith(prefix));
163
+ if (shouldSkip) continue;
164
+
165
+ // Apply 4-char minimum length filter
166
+ if (text.length < 4) continue;
167
+
168
+ // Extract timestamp from entry if present, else empty string
169
+ const timestamp = (entry.timestamp as string) ?? (msg.timestamp as string) ?? "";
170
+
171
+ results.push({ query: text, timestamp });
172
+ }
173
+
174
+ return results;
175
+ }
176
+
177
+ /**
178
+ * Parse a Claude Code session transcript into a ParsedSession.
179
+ * Returns null if no user queries are found after filtering.
180
+ */
181
+ export function parseSession(transcriptPath: string): ParsedSession | null {
182
+ const metrics = parseTranscript(transcriptPath);
183
+ const userQueries = extractAllUserQueries(transcriptPath);
184
+
185
+ if (userQueries.length === 0) return null;
186
+
187
+ const sessionId = basename(transcriptPath, ".jsonl");
188
+
189
+ // Determine timestamp: use first query's timestamp, or file mtime as fallback
190
+ let timestamp = userQueries[0].timestamp;
191
+ if (!timestamp) {
192
+ try {
193
+ timestamp = statSync(transcriptPath).mtime.toISOString();
194
+ } catch {
195
+ timestamp = new Date().toISOString();
196
+ }
197
+ }
198
+
199
+ return {
200
+ transcript_path: transcriptPath,
201
+ session_id: sessionId,
202
+ timestamp,
203
+ metrics,
204
+ user_queries: userQueries,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Write parsed session data to shared JSONL logs.
210
+ * Writes ONE QueryLogRecord per user query, ONE SessionTelemetryRecord per session,
211
+ * and ONE SkillUsageRecord per triggered skill.
212
+ */
213
+ export function writeSession(
214
+ session: ParsedSession,
215
+ dryRun = false,
216
+ queryLogPath: string = QUERY_LOG,
217
+ telemetryLogPath: string = TELEMETRY_LOG,
218
+ skillLogPath: string = SKILL_LOG,
219
+ ): void {
220
+ if (dryRun) {
221
+ console.log(
222
+ ` [DRY RUN] Would ingest: session=${session.session_id.slice(0, 12)}... ` +
223
+ `turns=${session.metrics.assistant_turns} queries=${session.user_queries.length} ` +
224
+ `skills=${JSON.stringify(session.metrics.skills_triggered)}`,
225
+ );
226
+ return;
227
+ }
228
+
229
+ // Write ONE query record per user query
230
+ for (const uq of session.user_queries) {
231
+ const queryRecord: QueryLogRecord = {
232
+ timestamp: uq.timestamp || session.timestamp,
233
+ session_id: session.session_id,
234
+ query: uq.query,
235
+ source: "claude_code_replay",
236
+ };
237
+ appendJsonl(queryLogPath, queryRecord, "all_queries");
238
+ }
239
+
240
+ // Write ONE telemetry record per session
241
+ const telemetry: SessionTelemetryRecord = {
242
+ timestamp: session.timestamp,
243
+ session_id: session.session_id,
244
+ cwd: "",
245
+ transcript_path: session.transcript_path,
246
+ tool_calls: session.metrics.tool_calls,
247
+ total_tool_calls: session.metrics.total_tool_calls,
248
+ bash_commands: session.metrics.bash_commands,
249
+ skills_triggered: session.metrics.skills_triggered,
250
+ assistant_turns: session.metrics.assistant_turns,
251
+ errors_encountered: session.metrics.errors_encountered,
252
+ transcript_chars: session.metrics.transcript_chars,
253
+ last_user_query: session.metrics.last_user_query,
254
+ source: "claude_code_replay",
255
+ };
256
+ appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
257
+
258
+ // Write ONE skill record per triggered skill
259
+ for (const skillName of session.metrics.skills_triggered) {
260
+ const skillRecord: SkillUsageRecord = {
261
+ timestamp: session.timestamp,
262
+ session_id: session.session_id,
263
+ skill_name: skillName,
264
+ skill_path: `(claude_code:${skillName})`,
265
+ query: session.metrics.last_user_query,
266
+ triggered: true,
267
+ source: "claude_code_replay",
268
+ };
269
+ appendJsonl(skillLogPath, skillRecord, "skill_usage");
270
+ }
271
+ }
272
+
273
+ // --- CLI main ---
274
+ export function cliMain(): void {
275
+ const { values } = parseArgs({
276
+ options: {
277
+ "projects-dir": { type: "string", default: CLAUDE_CODE_PROJECTS_DIR },
278
+ since: { type: "string" },
279
+ "dry-run": { type: "boolean", default: false },
280
+ force: { type: "boolean", default: false },
281
+ verbose: { type: "boolean", short: "v", default: false },
282
+ },
283
+ strict: true,
284
+ });
285
+
286
+ const projectsDir = values["projects-dir"] ?? CLAUDE_CODE_PROJECTS_DIR;
287
+ let since: Date | undefined;
288
+ if (values.since) {
289
+ since = new Date(values.since);
290
+ if (Number.isNaN(since.getTime())) {
291
+ console.error(
292
+ `Error: Invalid --since date: "${values.since}". Use a valid date format (e.g., 2026-01-01).`,
293
+ );
294
+ process.exit(1);
295
+ }
296
+ }
297
+
298
+ const transcriptFiles = findTranscriptFiles(projectsDir, since);
299
+ if (transcriptFiles.length === 0) {
300
+ console.log(`No transcript files found under ${projectsDir}/`);
301
+ console.log("Make sure you've run some Claude Code sessions.");
302
+ process.exit(0);
303
+ }
304
+
305
+ const alreadyIngested = values.force ? new Set<string>() : loadMarker(CLAUDE_CODE_MARKER);
306
+ const newIngested = new Set<string>();
307
+
308
+ const pending = transcriptFiles.filter((f) => !alreadyIngested.has(f));
309
+ console.log(
310
+ `Found ${transcriptFiles.length} transcript files, ${pending.length} not yet ingested.`,
311
+ );
312
+
313
+ if (since) {
314
+ console.log(` Filtering to sessions from ${values.since} onward.`);
315
+ }
316
+
317
+ let ingestedCount = 0;
318
+ let skippedCount = 0;
319
+
320
+ for (const transcriptFile of pending) {
321
+ const session = parseSession(transcriptFile);
322
+ if (session === null) {
323
+ if (values.verbose) {
324
+ console.log(` SKIP (empty/no queries): ${basename(transcriptFile)}`);
325
+ }
326
+ skippedCount += 1;
327
+ continue;
328
+ }
329
+
330
+ if (values.verbose || values["dry-run"]) {
331
+ console.log(` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${basename(transcriptFile)}`);
332
+ }
333
+
334
+ writeSession(session, values["dry-run"]);
335
+ newIngested.add(transcriptFile);
336
+ ingestedCount += 1;
337
+ }
338
+
339
+ if (!values["dry-run"]) {
340
+ saveMarker(CLAUDE_CODE_MARKER, new Set([...alreadyIngested, ...newIngested]));
341
+ }
342
+
343
+ console.log(`\nDone. Ingested ${ingestedCount} sessions, skipped ${skippedCount}.`);
344
+ if (newIngested.size > 0 && !values["dry-run"]) {
345
+ console.log(`Marker updated: ${CLAUDE_CODE_MARKER}`);
346
+ }
347
+ }
348
+
349
+ if (import.meta.main) {
350
+ cliMain();
351
+ }
@@ -21,7 +21,7 @@
21
21
  * bun codex-rollout.ts --force
22
22
  */
23
23
 
24
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
24
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { basename, join } from "node:path";
27
27
  import { parseArgs } from "node:util";