selftune 0.2.0 → 0.2.2

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 (122) hide show
  1. package/.claude/agents/diagnosis-analyst.md +20 -10
  2. package/.claude/agents/evolution-reviewer.md +14 -1
  3. package/.claude/agents/integration-guide.md +18 -6
  4. package/.claude/agents/pattern-analyst.md +18 -5
  5. package/CHANGELOG.md +12 -4
  6. package/README.md +43 -35
  7. package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  8. package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  9. package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  10. package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
  11. package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
  12. package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
  13. package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
  14. package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
  15. package/apps/local-dashboard/dist/favicon.png +0 -0
  16. package/apps/local-dashboard/dist/index.html +17 -0
  17. package/apps/local-dashboard/dist/logo.png +0 -0
  18. package/apps/local-dashboard/dist/logo.svg +9 -0
  19. package/cli/selftune/badge/badge-data.ts +1 -1
  20. package/cli/selftune/badge/badge.ts +4 -8
  21. package/cli/selftune/canonical-export.ts +183 -0
  22. package/cli/selftune/constants.ts +28 -0
  23. package/cli/selftune/contribute/contribute.ts +1 -1
  24. package/cli/selftune/cron/setup.ts +17 -17
  25. package/cli/selftune/dashboard-contract.ts +202 -0
  26. package/cli/selftune/dashboard-server.ts +653 -186
  27. package/cli/selftune/dashboard.ts +41 -176
  28. package/cli/selftune/eval/baseline.ts +5 -4
  29. package/cli/selftune/eval/composability-v2.ts +273 -0
  30. package/cli/selftune/eval/hooks-to-evals.ts +34 -15
  31. package/cli/selftune/eval/unit-test-cli.ts +1 -1
  32. package/cli/selftune/evolution/evidence.ts +26 -0
  33. package/cli/selftune/evolution/evolve-body.ts +105 -11
  34. package/cli/selftune/evolution/evolve.ts +371 -25
  35. package/cli/selftune/evolution/extract-patterns.ts +87 -29
  36. package/cli/selftune/evolution/rollback.ts +2 -2
  37. package/cli/selftune/grading/auto-grade.ts +200 -0
  38. package/cli/selftune/grading/grade-session.ts +448 -97
  39. package/cli/selftune/grading/results.ts +42 -0
  40. package/cli/selftune/hooks/prompt-log.ts +172 -2
  41. package/cli/selftune/hooks/session-stop.ts +123 -3
  42. package/cli/selftune/hooks/skill-eval.ts +119 -3
  43. package/cli/selftune/index.ts +395 -116
  44. package/cli/selftune/ingestors/claude-replay.ts +140 -114
  45. package/cli/selftune/ingestors/codex-rollout.ts +345 -46
  46. package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
  47. package/cli/selftune/ingestors/openclaw-ingest.ts +141 -8
  48. package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
  49. package/cli/selftune/init.ts +227 -14
  50. package/cli/selftune/last.ts +14 -5
  51. package/cli/selftune/localdb/db.ts +63 -0
  52. package/cli/selftune/localdb/materialize.ts +428 -0
  53. package/cli/selftune/localdb/queries.ts +376 -0
  54. package/cli/selftune/localdb/schema.ts +204 -0
  55. package/cli/selftune/monitoring/watch.ts +66 -15
  56. package/cli/selftune/normalization.ts +682 -0
  57. package/cli/selftune/observability.ts +19 -44
  58. package/cli/selftune/orchestrate.ts +1073 -0
  59. package/cli/selftune/quickstart.ts +203 -0
  60. package/cli/selftune/repair/skill-usage.ts +576 -0
  61. package/cli/selftune/schedule.ts +561 -0
  62. package/cli/selftune/status.ts +48 -26
  63. package/cli/selftune/sync.ts +627 -0
  64. package/cli/selftune/types.ts +148 -0
  65. package/cli/selftune/utils/canonical-log.ts +45 -0
  66. package/cli/selftune/utils/hooks.ts +41 -0
  67. package/cli/selftune/utils/html.ts +27 -0
  68. package/cli/selftune/utils/llm-call.ts +78 -20
  69. package/cli/selftune/utils/math.ts +10 -0
  70. package/cli/selftune/utils/query-filter.ts +139 -0
  71. package/cli/selftune/utils/skill-discovery.ts +340 -0
  72. package/cli/selftune/utils/skill-log.ts +68 -0
  73. package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
  74. package/cli/selftune/utils/transcript.ts +272 -26
  75. package/cli/selftune/workflows/discover.ts +254 -0
  76. package/cli/selftune/workflows/skill-md-writer.ts +288 -0
  77. package/cli/selftune/workflows/workflows.ts +188 -0
  78. package/package.json +21 -8
  79. package/packages/telemetry-contract/README.md +11 -0
  80. package/packages/telemetry-contract/fixtures/golden.json +87 -0
  81. package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
  82. package/packages/telemetry-contract/index.ts +1 -0
  83. package/packages/telemetry-contract/package.json +19 -0
  84. package/packages/telemetry-contract/src/index.ts +2 -0
  85. package/packages/telemetry-contract/src/types.ts +163 -0
  86. package/packages/telemetry-contract/src/validators.ts +109 -0
  87. package/skill/SKILL.md +84 -53
  88. package/skill/Workflows/AutoActivation.md +17 -16
  89. package/skill/Workflows/Badge.md +6 -0
  90. package/skill/Workflows/Baseline.md +46 -23
  91. package/skill/Workflows/Composability.md +12 -5
  92. package/skill/Workflows/Contribute.md +17 -14
  93. package/skill/Workflows/Cron.md +56 -79
  94. package/skill/Workflows/Dashboard.md +45 -34
  95. package/skill/Workflows/Doctor.md +30 -17
  96. package/skill/Workflows/Evals.md +64 -40
  97. package/skill/Workflows/EvolutionMemory.md +2 -0
  98. package/skill/Workflows/Evolve.md +102 -47
  99. package/skill/Workflows/EvolveBody.md +6 -6
  100. package/skill/Workflows/Grade.md +36 -31
  101. package/skill/Workflows/ImportSkillsBench.md +11 -5
  102. package/skill/Workflows/Ingest.md +43 -36
  103. package/skill/Workflows/Initialize.md +44 -30
  104. package/skill/Workflows/Orchestrate.md +139 -0
  105. package/skill/Workflows/Replay.md +39 -18
  106. package/skill/Workflows/Rollback.md +3 -3
  107. package/skill/Workflows/Schedule.md +61 -0
  108. package/skill/Workflows/Sync.md +88 -0
  109. package/skill/Workflows/UnitTest.md +34 -22
  110. package/skill/Workflows/Watch.md +14 -4
  111. package/skill/Workflows/Workflows.md +129 -0
  112. package/skill/assets/activation-rules-default.json +26 -0
  113. package/skill/assets/multi-skill-settings.json +63 -0
  114. package/skill/assets/single-skill-settings.json +57 -0
  115. package/skill/references/invocation-taxonomy.md +2 -2
  116. package/skill/references/logs.md +164 -2
  117. package/skill/references/setup-patterns.md +65 -0
  118. package/skill/references/version-history.md +40 -0
  119. package/skill/settings_snippet.json +1 -1
  120. package/templates/multi-skill-settings.json +7 -7
  121. package/templates/single-skill-settings.json +6 -6
  122. package/dashboard/index.html +0 -1680
@@ -0,0 +1,627 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * selftune sync — Source-truth telemetry sync across supported agent CLIs.
4
+ *
5
+ * This command is intentionally source-first:
6
+ * - Claude Code transcripts
7
+ * - Codex rollout logs
8
+ * - OpenCode session history
9
+ * - OpenClaw session history
10
+ *
11
+ * After syncing raw session/query/telemetry records, it rebuilds the repaired
12
+ * skill-usage overlay from Claude transcripts and Codex rollouts so monitoring,
13
+ * grading, and evolution are driven from source truth rather than hooks alone.
14
+ */
15
+
16
+ import { existsSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { parseArgs } from "node:util";
20
+ import {
21
+ CLAUDE_CODE_MARKER,
22
+ CLAUDE_CODE_PROJECTS_DIR,
23
+ CODEX_INGEST_MARKER,
24
+ OPENCLAW_AGENTS_DIR,
25
+ OPENCLAW_INGEST_MARKER,
26
+ OPENCODE_INGEST_MARKER,
27
+ QUERY_LOG,
28
+ REPAIRED_SKILL_LOG,
29
+ REPAIRED_SKILL_SESSIONS_MARKER,
30
+ SKILL_LOG,
31
+ TELEMETRY_LOG,
32
+ } from "./constants.js";
33
+ import {
34
+ findTranscriptFiles,
35
+ parseSession,
36
+ writeSession as writeClaudeReplaySession,
37
+ } from "./ingestors/claude-replay.js";
38
+ import {
39
+ DEFAULT_CODEX_HOME,
40
+ findSkillNames as findCodexSkillNames,
41
+ findRolloutFiles,
42
+ ingestFile as ingestCodexRollout,
43
+ parseRolloutFile,
44
+ } from "./ingestors/codex-rollout.js";
45
+ import {
46
+ findOpenClawSessions,
47
+ findOpenClawSkillNames,
48
+ parseOpenClawSession,
49
+ writeSession as writeOpenClawSession,
50
+ } from "./ingestors/openclaw-ingest.js";
51
+ import {
52
+ findSkillNames as findOpenCodeSkillNames,
53
+ readSessionsFromJsonFiles,
54
+ readSessionsFromSqlite,
55
+ writeSession as writeOpenCodeSession,
56
+ } from "./ingestors/opencode-ingest.js";
57
+ import {
58
+ rebuildSkillUsageFromCodexRollouts,
59
+ rebuildSkillUsageFromTranscripts,
60
+ } from "./repair/skill-usage.js";
61
+ import type { SkillUsageRecord } from "./types.js";
62
+ import { loadMarker, readJsonl, saveMarker } from "./utils/jsonl.js";
63
+ import { writeRepairedSkillUsageRecords } from "./utils/skill-log.js";
64
+
65
+ const XDG_DATA_HOME = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
66
+ const DEFAULT_OPENCODE_DATA_DIR = join(XDG_DATA_HOME, "opencode");
67
+
68
+ export interface SyncStepResult {
69
+ available: boolean;
70
+ scanned: number;
71
+ synced: number;
72
+ skipped: number;
73
+ }
74
+
75
+ export interface SyncPhaseTiming {
76
+ phase: string;
77
+ elapsed_ms: number;
78
+ }
79
+
80
+ export interface SyncResult {
81
+ since: string | null;
82
+ dry_run: boolean;
83
+ sources: {
84
+ claude: SyncStepResult;
85
+ codex: SyncStepResult;
86
+ opencode: SyncStepResult;
87
+ openclaw: SyncStepResult;
88
+ };
89
+ repair: {
90
+ ran: boolean;
91
+ repaired_sessions: number;
92
+ repaired_records: number;
93
+ codex_repaired_records: number;
94
+ };
95
+ timings: SyncPhaseTiming[];
96
+ total_elapsed_ms: number;
97
+ }
98
+
99
+ export interface SyncOptions {
100
+ projectsDir: string;
101
+ codexHome: string;
102
+ opencodeDataDir: string;
103
+ openclawAgentsDir: string;
104
+ skillLogPath: string;
105
+ repairedSkillLogPath: string;
106
+ repairedSessionsPath: string;
107
+ since?: Date;
108
+ dryRun: boolean;
109
+ force: boolean;
110
+ syncClaude: boolean;
111
+ syncCodex: boolean;
112
+ syncOpenCode: boolean;
113
+ syncOpenClaw: boolean;
114
+ rebuildSkillUsage: boolean;
115
+ }
116
+
117
+ export type SyncProgressCallback = (message: string) => void;
118
+
119
+ export interface SyncDeps {
120
+ syncClaude?: (options: SyncOptions) => SyncStepResult;
121
+ syncCodex?: (options: SyncOptions) => SyncStepResult;
122
+ syncOpenCode?: (options: SyncOptions) => SyncStepResult;
123
+ syncOpenClaw?: (options: SyncOptions) => SyncStepResult;
124
+ rebuildSkillUsage?: (options: SyncOptions) => {
125
+ repairedSessions: number;
126
+ repairedRecords: number;
127
+ codexRepairedRecords: number;
128
+ };
129
+ }
130
+
131
+ export function createDefaultSyncOptions(overrides: Partial<SyncOptions> = {}): SyncOptions {
132
+ return {
133
+ projectsDir: CLAUDE_CODE_PROJECTS_DIR,
134
+ codexHome: DEFAULT_CODEX_HOME,
135
+ opencodeDataDir: DEFAULT_OPENCODE_DATA_DIR,
136
+ openclawAgentsDir: OPENCLAW_AGENTS_DIR,
137
+ skillLogPath: SKILL_LOG,
138
+ repairedSkillLogPath: REPAIRED_SKILL_LOG,
139
+ repairedSessionsPath: REPAIRED_SKILL_SESSIONS_MARKER,
140
+ dryRun: false,
141
+ force: false,
142
+ syncClaude: true,
143
+ syncCodex: true,
144
+ syncOpenCode: true,
145
+ syncOpenClaw: true,
146
+ rebuildSkillUsage: true,
147
+ ...overrides,
148
+ };
149
+ }
150
+
151
+ /** Shared file-list cache so repair can reuse the ingest-phase scan. */
152
+ interface FileListCache {
153
+ claudeTranscripts?: string[];
154
+ codexRollouts?: string[];
155
+ }
156
+
157
+ function syncClaudeSource(
158
+ options: SyncOptions,
159
+ onProgress?: SyncProgressCallback,
160
+ cache?: FileListCache,
161
+ ): SyncStepResult {
162
+ if (!existsSync(options.projectsDir)) {
163
+ return { available: false, scanned: 0, synced: 0, skipped: 0 };
164
+ }
165
+
166
+ onProgress?.("scanning Claude transcripts...");
167
+ const transcriptFiles = findTranscriptFiles(options.projectsDir, options.since);
168
+ if (cache) cache.claudeTranscripts = transcriptFiles;
169
+
170
+ const alreadyIngested = options.force ? new Set<string>() : loadMarker(CLAUDE_CODE_MARKER);
171
+ const pending = transcriptFiles.filter((f) => !alreadyIngested.has(f));
172
+ onProgress?.(`found ${transcriptFiles.length} transcripts, ${pending.length} pending`);
173
+
174
+ const newIngested = new Set<string>();
175
+ let synced = 0;
176
+ let skipped = 0;
177
+
178
+ for (const transcriptFile of pending) {
179
+ const parsed = parseSession(transcriptFile);
180
+ if (!parsed) {
181
+ skipped += 1;
182
+ continue;
183
+ }
184
+ writeClaudeReplaySession(
185
+ parsed,
186
+ options.dryRun,
187
+ QUERY_LOG,
188
+ TELEMETRY_LOG,
189
+ options.skillLogPath,
190
+ );
191
+ newIngested.add(transcriptFile);
192
+ synced += 1;
193
+ }
194
+
195
+ if (!options.dryRun && newIngested.size > 0) {
196
+ saveMarker(CLAUDE_CODE_MARKER, new Set([...alreadyIngested, ...newIngested]));
197
+ }
198
+
199
+ return {
200
+ available: true,
201
+ scanned: transcriptFiles.length,
202
+ synced,
203
+ skipped,
204
+ };
205
+ }
206
+
207
+ function syncCodexSource(
208
+ options: SyncOptions,
209
+ onProgress?: SyncProgressCallback,
210
+ cache?: FileListCache,
211
+ ): SyncStepResult {
212
+ onProgress?.("scanning Codex rollouts...");
213
+ const rolloutFiles = findRolloutFiles(options.codexHome, options.since);
214
+ if (cache) cache.codexRollouts = rolloutFiles;
215
+
216
+ if (rolloutFiles.length === 0 && !existsSync(join(options.codexHome, "sessions"))) {
217
+ return { available: false, scanned: 0, synced: 0, skipped: 0 };
218
+ }
219
+
220
+ const alreadyIngested = options.force ? new Set<string>() : loadMarker(CODEX_INGEST_MARKER);
221
+ const pending = rolloutFiles.filter((f) => !alreadyIngested.has(f));
222
+ onProgress?.(`found ${rolloutFiles.length} rollouts, ${pending.length} pending`);
223
+
224
+ const skillNames = findCodexSkillNames();
225
+ const newIngested = new Set<string>();
226
+ let synced = 0;
227
+ let skipped = 0;
228
+
229
+ for (const rolloutFile of pending) {
230
+ const parsed = parseRolloutFile(rolloutFile, skillNames);
231
+ if (!parsed) {
232
+ skipped += 1;
233
+ continue;
234
+ }
235
+ ingestCodexRollout(parsed, options.dryRun, QUERY_LOG, TELEMETRY_LOG, options.skillLogPath);
236
+ newIngested.add(rolloutFile);
237
+ synced += 1;
238
+ }
239
+
240
+ if (!options.dryRun && newIngested.size > 0) {
241
+ saveMarker(CODEX_INGEST_MARKER, new Set([...alreadyIngested, ...newIngested]));
242
+ }
243
+
244
+ return {
245
+ available: true,
246
+ scanned: rolloutFiles.length,
247
+ synced,
248
+ skipped,
249
+ };
250
+ }
251
+
252
+ function syncOpenCodeSource(
253
+ options: SyncOptions,
254
+ onProgress?: SyncProgressCallback,
255
+ ): SyncStepResult {
256
+ if (!existsSync(options.opencodeDataDir)) {
257
+ return { available: false, scanned: 0, synced: 0, skipped: 0 };
258
+ }
259
+
260
+ onProgress?.("scanning OpenCode sessions...");
261
+ const dbPath = join(options.opencodeDataDir, "opencode.db");
262
+ const storageDir = join(options.opencodeDataDir, "storage");
263
+ const skillNames = findOpenCodeSkillNames();
264
+ const sinceTs = options.since ? options.since.getTime() / 1000 : null;
265
+ const allSessions = existsSync(dbPath)
266
+ ? readSessionsFromSqlite(dbPath, sinceTs, skillNames)
267
+ : existsSync(storageDir)
268
+ ? readSessionsFromJsonFiles(storageDir, sinceTs, skillNames)
269
+ : [];
270
+
271
+ if (allSessions.length === 0 && !existsSync(dbPath) && !existsSync(storageDir)) {
272
+ return { available: false, scanned: 0, synced: 0, skipped: 0 };
273
+ }
274
+
275
+ const alreadyIngested = options.force ? new Set<string>() : loadMarker(OPENCODE_INGEST_MARKER);
276
+ const pending = allSessions.filter((session) => !alreadyIngested.has(session.session_id));
277
+ onProgress?.(`found ${allSessions.length} sessions, ${pending.length} pending`);
278
+ const newIngested = new Set<string>();
279
+
280
+ for (const session of pending) {
281
+ writeOpenCodeSession(session, options.dryRun, QUERY_LOG, TELEMETRY_LOG, options.skillLogPath);
282
+ newIngested.add(session.session_id);
283
+ }
284
+
285
+ if (!options.dryRun && newIngested.size > 0) {
286
+ saveMarker(OPENCODE_INGEST_MARKER, new Set([...alreadyIngested, ...newIngested]));
287
+ }
288
+
289
+ return {
290
+ available: true,
291
+ scanned: allSessions.length,
292
+ synced: pending.length,
293
+ skipped: 0,
294
+ };
295
+ }
296
+
297
+ function syncOpenClawSource(
298
+ options: SyncOptions,
299
+ onProgress?: SyncProgressCallback,
300
+ ): SyncStepResult {
301
+ if (!existsSync(options.openclawAgentsDir)) {
302
+ return { available: false, scanned: 0, synced: 0, skipped: 0 };
303
+ }
304
+
305
+ onProgress?.("scanning OpenClaw sessions...");
306
+ const sinceTs = options.since ? options.since.getTime() : null;
307
+ const allSessions = findOpenClawSessions(options.openclawAgentsDir, sinceTs);
308
+ const skillNames = findOpenClawSkillNames(options.openclawAgentsDir);
309
+ const alreadyIngested = options.force ? new Set<string>() : loadMarker(OPENCLAW_INGEST_MARKER);
310
+ const pending = allSessions.filter((session) => !alreadyIngested.has(session.sessionId));
311
+ onProgress?.(`found ${allSessions.length} sessions, ${pending.length} pending`);
312
+ const newIngested = new Set<string>();
313
+ let synced = 0;
314
+ let skipped = 0;
315
+
316
+ for (const sessionFile of pending) {
317
+ const session = parseOpenClawSession(sessionFile.filePath, skillNames);
318
+ if (!session.session_id || !session.timestamp) {
319
+ skipped += 1;
320
+ continue;
321
+ }
322
+ writeOpenClawSession(session, options.dryRun, QUERY_LOG, TELEMETRY_LOG, options.skillLogPath);
323
+ newIngested.add(sessionFile.sessionId);
324
+ synced += 1;
325
+ }
326
+
327
+ if (!options.dryRun && newIngested.size > 0) {
328
+ saveMarker(OPENCLAW_INGEST_MARKER, new Set([...alreadyIngested, ...newIngested]));
329
+ }
330
+
331
+ return {
332
+ available: true,
333
+ scanned: allSessions.length,
334
+ synced,
335
+ skipped,
336
+ };
337
+ }
338
+
339
+ function rebuildSkillUsageOverlay(
340
+ options: SyncOptions,
341
+ onProgress?: SyncProgressCallback,
342
+ cache?: FileListCache,
343
+ ): {
344
+ repairedSessions: number;
345
+ repairedRecords: number;
346
+ codexRepairedRecords: number;
347
+ } {
348
+ // Reuse cached file lists from ingest phase when available to avoid re-walking the filesystem
349
+ const transcriptPaths =
350
+ cache?.claudeTranscripts ?? findTranscriptFiles(options.projectsDir, options.since);
351
+ const rolloutPaths = cache?.codexRollouts ?? findRolloutFiles(options.codexHome, options.since);
352
+
353
+ const reusedClaude = cache?.claudeTranscripts ? " (cached)" : "";
354
+ const reusedCodex = cache?.codexRollouts ? " (cached)" : "";
355
+ onProgress?.(
356
+ `repairing from ${transcriptPaths.length} transcripts${reusedClaude}, ${rolloutPaths.length} rollouts${reusedCodex}`,
357
+ );
358
+
359
+ const rawSkillRecords = readJsonl<SkillUsageRecord>(options.skillLogPath);
360
+ const { repairedRecords, repairedSessionIds } = rebuildSkillUsageFromTranscripts(
361
+ transcriptPaths,
362
+ rawSkillRecords,
363
+ process.env.HOME ?? "",
364
+ options.codexHome,
365
+ );
366
+ const { records: codexRecords, sessionIds: codexSessionIds } = rebuildSkillUsageFromCodexRollouts(
367
+ rolloutPaths,
368
+ rawSkillRecords,
369
+ process.env.HOME ?? "",
370
+ options.codexHome,
371
+ );
372
+
373
+ for (const sessionId of codexSessionIds) repairedSessionIds.add(sessionId);
374
+ repairedRecords.push(...codexRecords);
375
+
376
+ if (!options.dryRun) {
377
+ writeRepairedSkillUsageRecords(
378
+ repairedRecords,
379
+ repairedSessionIds,
380
+ options.repairedSkillLogPath,
381
+ options.repairedSessionsPath,
382
+ );
383
+ }
384
+
385
+ onProgress?.(
386
+ `repaired ${repairedRecords.length} records across ${repairedSessionIds.size} sessions`,
387
+ );
388
+
389
+ return {
390
+ repairedSessions: repairedSessionIds.size,
391
+ repairedRecords: repairedRecords.length,
392
+ codexRepairedRecords: codexRecords.length,
393
+ };
394
+ }
395
+
396
+ function timePhase<T>(name: string, fn: () => T, timings: SyncPhaseTiming[]): T {
397
+ const start = performance.now();
398
+ const result = fn();
399
+ timings.push({ phase: name, elapsed_ms: Math.round(performance.now() - start) });
400
+ return result;
401
+ }
402
+
403
+ export function syncSources(
404
+ options: SyncOptions,
405
+ deps: SyncDeps = {},
406
+ onProgress?: SyncProgressCallback,
407
+ ): SyncResult {
408
+ const totalStart = performance.now();
409
+ const timings: SyncPhaseTiming[] = [];
410
+ const cache: FileListCache = {};
411
+
412
+ const runClaude = deps.syncClaude;
413
+ const runCodex = deps.syncCodex;
414
+ const runOpenCode = deps.syncOpenCode;
415
+ const runOpenClaw = deps.syncOpenClaw;
416
+ const runRepair = deps.rebuildSkillUsage;
417
+
418
+ const disabledStep: SyncStepResult = { available: false, scanned: 0, synced: 0, skipped: 0 };
419
+
420
+ onProgress?.("starting sync...");
421
+
422
+ const claude = options.syncClaude
423
+ ? timePhase(
424
+ "claude",
425
+ () => (runClaude ? runClaude(options) : syncClaudeSource(options, onProgress, cache)),
426
+ timings,
427
+ )
428
+ : disabledStep;
429
+
430
+ const codex = options.syncCodex
431
+ ? timePhase(
432
+ "codex",
433
+ () => (runCodex ? runCodex(options) : syncCodexSource(options, onProgress, cache)),
434
+ timings,
435
+ )
436
+ : disabledStep;
437
+
438
+ const opencode = options.syncOpenCode
439
+ ? timePhase(
440
+ "opencode",
441
+ () => (runOpenCode ? runOpenCode(options) : syncOpenCodeSource(options, onProgress)),
442
+ timings,
443
+ )
444
+ : disabledStep;
445
+
446
+ const openclaw = options.syncOpenClaw
447
+ ? timePhase(
448
+ "openclaw",
449
+ () => (runOpenClaw ? runOpenClaw(options) : syncOpenClawSource(options, onProgress)),
450
+ timings,
451
+ )
452
+ : disabledStep;
453
+
454
+ const repair = options.rebuildSkillUsage
455
+ ? timePhase(
456
+ "repair",
457
+ () =>
458
+ runRepair ? runRepair(options) : rebuildSkillUsageOverlay(options, onProgress, cache),
459
+ timings,
460
+ )
461
+ : { repairedSessions: 0, repairedRecords: 0, codexRepairedRecords: 0 };
462
+
463
+ const totalElapsed = Math.round(performance.now() - totalStart);
464
+
465
+ return {
466
+ since: options.since ? options.since.toISOString() : null,
467
+ dry_run: options.dryRun,
468
+ sources: { claude, codex, opencode, openclaw },
469
+ repair: {
470
+ ran: options.rebuildSkillUsage,
471
+ repaired_sessions: repair.repairedSessions,
472
+ repaired_records: repair.repairedRecords,
473
+ codex_repaired_records: repair.codexRepairedRecords,
474
+ },
475
+ timings,
476
+ total_elapsed_ms: totalElapsed,
477
+ };
478
+ }
479
+
480
+ function formatMs(ms: number): string {
481
+ if (ms < 1000) return `${ms}ms`;
482
+ return `${(ms / 1000).toFixed(1)}s`;
483
+ }
484
+
485
+ function formatStepLine(label: string, step: SyncStepResult, timing?: SyncPhaseTiming): string {
486
+ if (!step.available) return ` ${label}: not available`;
487
+ const parts = [`scanned ${step.scanned}`];
488
+ if (step.synced > 0) parts.push(`synced ${step.synced}`);
489
+ if (step.skipped > 0) parts.push(`skipped ${step.skipped}`);
490
+ const time = timing ? ` (${formatMs(timing.elapsed_ms)})` : "";
491
+ return ` ${label}: ${parts.join(", ")}${time}`;
492
+ }
493
+
494
+ export function cliMain(): void {
495
+ const { values } = parseArgs({
496
+ options: {
497
+ "projects-dir": { type: "string", default: CLAUDE_CODE_PROJECTS_DIR },
498
+ "codex-home": { type: "string", default: DEFAULT_CODEX_HOME },
499
+ "opencode-data-dir": { type: "string", default: DEFAULT_OPENCODE_DATA_DIR },
500
+ "openclaw-agents-dir": { type: "string", default: OPENCLAW_AGENTS_DIR },
501
+ "skill-log": { type: "string", default: SKILL_LOG },
502
+ "repaired-skill-log": { type: "string", default: REPAIRED_SKILL_LOG },
503
+ "repaired-sessions-marker": { type: "string", default: REPAIRED_SKILL_SESSIONS_MARKER },
504
+ since: { type: "string" },
505
+ "dry-run": { type: "boolean", default: false },
506
+ force: { type: "boolean", default: false },
507
+ "no-claude": { type: "boolean", default: false },
508
+ "no-codex": { type: "boolean", default: false },
509
+ "no-opencode": { type: "boolean", default: false },
510
+ "no-openclaw": { type: "boolean", default: false },
511
+ "no-repair": { type: "boolean", default: false },
512
+ json: { type: "boolean", default: false },
513
+ help: { type: "boolean", short: "h", default: false },
514
+ },
515
+ strict: true,
516
+ });
517
+
518
+ if (values.help) {
519
+ console.log(`selftune sync — Source-truth telemetry sync
520
+
521
+ Usage:
522
+ selftune sync [options]
523
+
524
+ Options:
525
+ --projects-dir <dir> Claude transcript directory (default: ~/.claude/projects)
526
+ --codex-home <dir> Codex home directory (default: ~/.codex)
527
+ --opencode-data-dir <dir> OpenCode data directory
528
+ --openclaw-agents-dir <dir> OpenClaw agents directory
529
+ --skill-log <path> Raw skill usage log path
530
+ --repaired-skill-log <path> Repaired overlay log path
531
+ --repaired-sessions-marker <p> Repaired session marker path
532
+ --since <date> Only sync sessions modified on/after date
533
+ --dry-run Show summary without writing files
534
+ --force Ignore per-source markers and rescan everything
535
+ --no-claude Skip Claude transcript replay
536
+ --no-codex Skip Codex rollout ingest
537
+ --no-opencode Skip OpenCode ingest
538
+ --no-openclaw Skip OpenClaw ingest
539
+ --no-repair Skip rebuilt skill-usage overlay
540
+ --json Output raw JSON instead of human-readable summary
541
+ -h, --help Show this help`);
542
+ process.exit(0);
543
+ }
544
+
545
+ let since: Date | undefined;
546
+ if (values.since) {
547
+ since = new Date(values.since);
548
+ if (Number.isNaN(since.getTime())) {
549
+ console.error(`[ERROR] Invalid --since date: ${values.since}`);
550
+ process.exit(1);
551
+ }
552
+ }
553
+
554
+ // JSON output: explicit --json flag, or auto when stdout is not a TTY (preserves contract for automation)
555
+ const jsonOutput = (values.json ?? false) || !process.stdout.isTTY;
556
+
557
+ const onProgress: SyncProgressCallback | undefined = jsonOutput
558
+ ? undefined
559
+ : (msg) => {
560
+ process.stderr.write(` ${msg}\n`);
561
+ };
562
+
563
+ if (!jsonOutput) {
564
+ const flags: string[] = [];
565
+ if (values.force) flags.push("--force");
566
+ if (values["dry-run"]) flags.push("--dry-run");
567
+ if (since) flags.push(`--since ${values.since}`);
568
+ process.stderr.write(`selftune sync${flags.length ? ` ${flags.join(" ")}` : ""}\n`);
569
+ }
570
+
571
+ const result = syncSources(
572
+ createDefaultSyncOptions({
573
+ projectsDir: values["projects-dir"] ?? CLAUDE_CODE_PROJECTS_DIR,
574
+ codexHome: values["codex-home"] ?? DEFAULT_CODEX_HOME,
575
+ opencodeDataDir: values["opencode-data-dir"] ?? DEFAULT_OPENCODE_DATA_DIR,
576
+ openclawAgentsDir: values["openclaw-agents-dir"] ?? OPENCLAW_AGENTS_DIR,
577
+ skillLogPath: values["skill-log"] ?? SKILL_LOG,
578
+ repairedSkillLogPath: values["repaired-skill-log"] ?? REPAIRED_SKILL_LOG,
579
+ repairedSessionsPath: values["repaired-sessions-marker"] ?? REPAIRED_SKILL_SESSIONS_MARKER,
580
+ since,
581
+ dryRun: values["dry-run"] ?? false,
582
+ force: values.force ?? false,
583
+ syncClaude: !(values["no-claude"] ?? false),
584
+ syncCodex: !(values["no-codex"] ?? false),
585
+ syncOpenCode: !(values["no-opencode"] ?? false),
586
+ syncOpenClaw: !(values["no-openclaw"] ?? false),
587
+ rebuildSkillUsage: !(values["no-repair"] ?? false),
588
+ }),
589
+ {},
590
+ onProgress,
591
+ );
592
+
593
+ if (jsonOutput) {
594
+ console.log(JSON.stringify(result, null, 2));
595
+ } else {
596
+ const timingMap = new Map(result.timings.map((t) => [t.phase, t]));
597
+
598
+ process.stderr.write("\nSources:\n");
599
+ process.stderr.write(
600
+ `${formatStepLine("Claude", result.sources.claude, timingMap.get("claude"))}\n`,
601
+ );
602
+ process.stderr.write(
603
+ `${formatStepLine("Codex", result.sources.codex, timingMap.get("codex"))}\n`,
604
+ );
605
+ process.stderr.write(
606
+ `${formatStepLine("OpenCode", result.sources.opencode, timingMap.get("opencode"))}\n`,
607
+ );
608
+ process.stderr.write(
609
+ `${formatStepLine("OpenClaw", result.sources.openclaw, timingMap.get("openclaw"))}\n`,
610
+ );
611
+
612
+ if (result.repair.ran) {
613
+ const repairTiming = timingMap.get("repair");
614
+ const repairTime = repairTiming ? ` (${formatMs(repairTiming.elapsed_ms)})` : "";
615
+ process.stderr.write(
616
+ `\nRepair: ${result.repair.repaired_records} records, ` +
617
+ `${result.repair.repaired_sessions} sessions${repairTime}\n`,
618
+ );
619
+ }
620
+
621
+ process.stderr.write(`\nDone in ${formatMs(result.total_elapsed_ms)}\n`);
622
+ }
623
+ }
624
+
625
+ if (import.meta.main) {
626
+ cliMain();
627
+ }