selftune 0.2.6 → 0.2.9

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 (119) hide show
  1. package/README.md +1 -0
  2. package/apps/local-dashboard/dist/assets/index-Bs3Y4ixf.css +1 -0
  3. package/apps/local-dashboard/dist/assets/index-C4UYGWKr.js +15 -0
  4. package/apps/local-dashboard/dist/assets/vendor-react-BQH_6WrG.js +60 -0
  5. package/apps/local-dashboard/dist/assets/{vendor-table-B7VF2Ipl.js → vendor-table-dK1QMLq9.js} +1 -1
  6. package/apps/local-dashboard/dist/assets/{vendor-ui-r2k_Ku_V.js → vendor-ui-CO2mrx6e.js} +60 -65
  7. package/apps/local-dashboard/dist/index.html +5 -5
  8. package/cli/selftune/activation-rules.ts +57 -18
  9. package/cli/selftune/agent-guidance.ts +96 -0
  10. package/cli/selftune/alpha-identity.ts +156 -0
  11. package/cli/selftune/alpha-upload/build-payloads.ts +151 -0
  12. package/cli/selftune/alpha-upload/client.ts +113 -0
  13. package/cli/selftune/alpha-upload/flush.ts +191 -0
  14. package/cli/selftune/alpha-upload/index.ts +194 -0
  15. package/cli/selftune/alpha-upload/queue.ts +252 -0
  16. package/cli/selftune/alpha-upload/stage-canonical.ts +251 -0
  17. package/cli/selftune/alpha-upload-contract.ts +52 -0
  18. package/cli/selftune/auth/device-code.ts +110 -0
  19. package/cli/selftune/auto-update.ts +130 -0
  20. package/cli/selftune/badge/badge.ts +19 -9
  21. package/cli/selftune/canonical-export.ts +16 -3
  22. package/cli/selftune/constants.ts +28 -8
  23. package/cli/selftune/contribute/bundle.ts +33 -5
  24. package/cli/selftune/dashboard-contract.ts +32 -1
  25. package/cli/selftune/dashboard-server.ts +215 -693
  26. package/cli/selftune/dashboard.ts +1 -1
  27. package/cli/selftune/eval/baseline.ts +11 -7
  28. package/cli/selftune/eval/hooks-to-evals.ts +39 -15
  29. package/cli/selftune/eval/synthetic-evals.ts +54 -1
  30. package/cli/selftune/evolution/audit.ts +24 -19
  31. package/cli/selftune/evolution/constitutional.ts +176 -0
  32. package/cli/selftune/evolution/evidence.ts +18 -13
  33. package/cli/selftune/evolution/evolve-body.ts +104 -7
  34. package/cli/selftune/evolution/evolve.ts +195 -22
  35. package/cli/selftune/evolution/propose-body.ts +18 -1
  36. package/cli/selftune/evolution/propose-description.ts +27 -2
  37. package/cli/selftune/evolution/rollback.ts +11 -15
  38. package/cli/selftune/export.ts +84 -0
  39. package/cli/selftune/grading/auto-grade.ts +14 -4
  40. package/cli/selftune/grading/grade-session.ts +17 -6
  41. package/cli/selftune/hooks/auto-activate.ts +5 -0
  42. package/cli/selftune/hooks/evolution-guard.ts +25 -11
  43. package/cli/selftune/hooks/prompt-log.ts +23 -9
  44. package/cli/selftune/hooks/session-stop.ts +78 -15
  45. package/cli/selftune/hooks/skill-eval.ts +189 -10
  46. package/cli/selftune/index.ts +274 -2
  47. package/cli/selftune/ingestors/claude-replay.ts +48 -21
  48. package/cli/selftune/init.ts +260 -49
  49. package/cli/selftune/last.ts +7 -7
  50. package/cli/selftune/localdb/db.ts +90 -10
  51. package/cli/selftune/localdb/direct-write.ts +573 -0
  52. package/cli/selftune/localdb/materialize.ts +296 -42
  53. package/cli/selftune/localdb/queries.ts +482 -32
  54. package/cli/selftune/localdb/schema.ts +153 -1
  55. package/cli/selftune/monitoring/watch.ts +27 -8
  56. package/cli/selftune/normalization.ts +88 -15
  57. package/cli/selftune/observability.ts +257 -5
  58. package/cli/selftune/orchestrate.ts +176 -53
  59. package/cli/selftune/quickstart.ts +34 -10
  60. package/cli/selftune/repair/skill-usage.ts +15 -2
  61. package/cli/selftune/routes/actions.ts +77 -0
  62. package/cli/selftune/routes/badge.ts +66 -0
  63. package/cli/selftune/routes/doctor.ts +12 -0
  64. package/cli/selftune/routes/index.ts +14 -0
  65. package/cli/selftune/routes/orchestrate-runs.ts +13 -0
  66. package/cli/selftune/routes/overview.ts +14 -0
  67. package/cli/selftune/routes/report.ts +293 -0
  68. package/cli/selftune/routes/skill-report.ts +230 -0
  69. package/cli/selftune/status.ts +203 -7
  70. package/cli/selftune/sync.ts +14 -1
  71. package/cli/selftune/types.ts +52 -2
  72. package/cli/selftune/utils/jsonl.ts +58 -1
  73. package/cli/selftune/utils/selftune-meta.ts +38 -0
  74. package/cli/selftune/utils/skill-log.ts +30 -4
  75. package/cli/selftune/utils/transcript.ts +15 -0
  76. package/cli/selftune/workflows/workflows.ts +7 -6
  77. package/package.json +11 -6
  78. package/packages/telemetry-contract/fixtures/complete-push.ts +184 -0
  79. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +58 -0
  80. package/packages/telemetry-contract/fixtures/golden.json +1 -0
  81. package/packages/telemetry-contract/fixtures/index.ts +4 -0
  82. package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +40 -0
  83. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +79 -0
  84. package/packages/telemetry-contract/package.json +6 -1
  85. package/packages/telemetry-contract/src/schemas.ts +196 -0
  86. package/packages/telemetry-contract/src/types.ts +3 -1
  87. package/packages/telemetry-contract/src/validators.ts +3 -1
  88. package/packages/telemetry-contract/tests/compatibility.test.ts +144 -0
  89. package/packages/ui/package.json +4 -0
  90. package/packages/ui/src/components/ActivityTimeline.tsx +61 -29
  91. package/packages/ui/src/components/section-cards.tsx +31 -14
  92. package/packages/ui/src/types.ts +1 -0
  93. package/skill/SKILL.md +214 -174
  94. package/skill/Workflows/AlphaUpload.md +45 -0
  95. package/skill/Workflows/Baseline.md +18 -12
  96. package/skill/Workflows/Composability.md +3 -3
  97. package/skill/Workflows/Dashboard.md +39 -91
  98. package/skill/Workflows/Doctor.md +93 -66
  99. package/skill/Workflows/Evals.md +49 -40
  100. package/skill/Workflows/Evolve.md +76 -28
  101. package/skill/Workflows/EvolveBody.md +37 -38
  102. package/skill/Workflows/Initialize.md +145 -26
  103. package/skill/Workflows/Orchestrate.md +11 -2
  104. package/skill/Workflows/Sync.md +23 -0
  105. package/skill/Workflows/Watch.md +2 -5
  106. package/skill/agents/diagnosis-analyst.md +163 -0
  107. package/skill/agents/evolution-reviewer.md +149 -0
  108. package/skill/agents/integration-guide.md +154 -0
  109. package/skill/agents/pattern-analyst.md +149 -0
  110. package/skill/assets/multi-skill-settings.json +1 -1
  111. package/skill/assets/single-skill-settings.json +1 -1
  112. package/skill/references/interactive-config.md +39 -0
  113. package/skill/references/invocation-taxonomy.md +34 -0
  114. package/skill/references/logs.md +15 -1
  115. package/skill/references/setup-patterns.md +3 -3
  116. package/skill/settings_snippet.json +1 -1
  117. package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +0 -1
  118. package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +0 -15
  119. package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +0 -60
@@ -2,9 +2,10 @@
2
2
  /**
3
3
  * Claude Code PostToolUse hook: skill-eval.ts
4
4
  *
5
- * Fires whenever Claude reads a file. If that file is a SKILL.md, this hook:
5
+ * Fires whenever Claude reads a file or invokes a skill. If the file is a
6
+ * SKILL.md or the tool is a Skill invocation, this hook:
6
7
  * 1. Finds the triggering user query from the transcript JSONL
7
- * 2. Appends a usage record to ~/.claude/skill_usage_log.jsonl
8
+ * 2. Writes a usage record to SQLite via writeSkillUsageToDb()
8
9
  *
9
10
  * This builds a real-usage eval dataset over time, seeding the
10
11
  * `should_trigger: true` half of trigger evals.
@@ -23,7 +24,7 @@ import {
23
24
  getLatestPromptIdentity,
24
25
  } from "../normalization.js";
25
26
  import type { PostToolUsePayload, SkillUsageRecord } from "../types.js";
26
- import { appendJsonl } from "../utils/jsonl.js";
27
+
27
28
  import { classifySkillPath } from "../utils/skill-discovery.js";
28
29
  import { getLastUserMessage } from "../utils/transcript.js";
29
30
 
@@ -94,17 +95,26 @@ export function countSkillToolInvocations(transcriptPath: string, skillName: str
94
95
  * Core processing logic, exported for testability.
95
96
  * Returns the record that was appended, or null if skipped.
96
97
  *
97
- * To reduce false triggers, checks whether the Read of SKILL.md was
98
+ * Handles two PostToolUse event types:
99
+ * - Read: when a SKILL.md file is read (original path)
100
+ * - Skill: when a skill is explicitly invoked via the Skill tool
101
+ *
102
+ * For Read events, checks whether the Read of SKILL.md was
98
103
  * preceded by an actual Skill tool invocation in the same transcript.
99
104
  * If not, the record is still logged but marked as triggered: false.
100
105
  */
101
- export function processToolUse(
106
+ export async function processToolUse(
102
107
  payload: PostToolUsePayload,
103
108
  logPath: string = SKILL_LOG,
104
109
  canonicalLogPath: string = CANONICAL_LOG,
105
110
  promptStatePath?: string,
106
- ): SkillUsageRecord | null {
107
- // Only care about Read tool
111
+ ): Promise<SkillUsageRecord | null> {
112
+ // Handle Skill tool invocations (e.g., Skill(selftune))
113
+ if (payload.tool_name === "Skill") {
114
+ return await processSkillToolUse(payload, logPath, canonicalLogPath, promptStatePath);
115
+ }
116
+
117
+ // Only care about Read tool for SKILL.md detection
108
118
  if (payload.tool_name !== "Read") return null;
109
119
 
110
120
  const rawPath = payload.tool_input?.file_path;
@@ -132,11 +142,10 @@ export function processToolUse(
132
142
  ...skillPathMetadata,
133
143
  query,
134
144
  triggered: wasInvoked,
145
+ invocation_type: "contextual",
135
146
  source: "claude_code",
136
147
  };
137
148
 
138
- appendJsonl(logPath, record);
139
-
140
149
  const baseInput: CanonicalBaseInput = {
141
150
  platform: "claude_code",
142
151
  capture_mode: "hook",
@@ -155,6 +164,7 @@ export function processToolUse(
155
164
  const { invocation_mode, confidence } = deriveInvocationMode({
156
165
  has_skill_tool_call: wasInvoked,
157
166
  has_skill_md_read: !wasInvoked,
167
+ hook_invocation_type: "contextual",
158
168
  });
159
169
  const canonical = buildCanonicalSkillInvocation({
160
170
  ...baseInput,
@@ -172,6 +182,175 @@ export function processToolUse(
172
182
  confidence,
173
183
  tool_name: payload.tool_name,
174
184
  });
185
+
186
+ // Write unified record to skill_invocations (replaces separate writeSkillUsageToDb call)
187
+ try {
188
+ const { writeSkillCheckToDb } = await import("../localdb/direct-write.js");
189
+ writeSkillCheckToDb({
190
+ ...canonical,
191
+ query: record.query,
192
+ skill_path: record.skill_path,
193
+ skill_scope: record.skill_scope,
194
+ source: record.source,
195
+ });
196
+ } catch {
197
+ /* hooks must never block */
198
+ }
199
+
200
+ appendCanonicalRecord(canonical, canonicalLogPath);
201
+
202
+ return record;
203
+ }
204
+
205
+ /**
206
+ * Classify how a Skill tool invocation was triggered:
207
+ *
208
+ * explicit — User typed /skillName (slash command) or skill was already loaded
209
+ * implicit — User mentioned the skill by name in their prompt; Claude invoked it
210
+ * inferred — User never mentioned the skill; Claude chose it autonomously
211
+ *
212
+ * Examples:
213
+ * "/selftune" → explicit (slash command)
214
+ * "setup selftune" → implicit (user named the skill)
215
+ * "show me the dashboard" → Browser → inferred (user never said "browser")
216
+ */
217
+ function classifyInvocationType(
218
+ query: string,
219
+ skillName: string,
220
+ ): "explicit" | "implicit" | "inferred" {
221
+ const trimmed = query.trim();
222
+ const skillLower = skillName.toLowerCase();
223
+
224
+ // /selftune or /selftune args
225
+ if (trimmed.toLowerCase().startsWith(`/${skillLower}`)) return "explicit";
226
+
227
+ // <command-name>/selftune</command-name> pattern (skill already loaded)
228
+ if (trimmed.includes(`<command-name>/${skillLower}</command-name>`)) return "explicit";
229
+ if (trimmed.includes(`<command-name>${skillLower}</command-name>`)) return "explicit";
230
+
231
+ // User mentioned the skill name in their prompt (case-insensitive word boundary)
232
+ const mentionPattern = new RegExp(
233
+ `\\b${skillLower.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
234
+ "i",
235
+ );
236
+ if (mentionPattern.test(trimmed)) return "implicit";
237
+
238
+ // Claude chose this skill entirely on its own
239
+ return "inferred";
240
+ }
241
+
242
+ /**
243
+ * Handle Skill tool invocations (e.g., Skill(selftune), Skill(Browser)).
244
+ * The tool_input contains { skill: "skillName", args?: "..." }.
245
+ * Classifies as explicit, implicit, or inferred based on user prompt.
246
+ */
247
+ /**
248
+ * Detect if the current transcript belongs to a subagent.
249
+ * Returns the agent type (e.g., "Explore", "Engineer") or "main".
250
+ */
251
+ function detectAgentType(transcriptPath: string): string {
252
+ if (!transcriptPath) return "main";
253
+ try {
254
+ // Subagent transcripts live under .../subagents/agent-<id>.jsonl
255
+ if (!/[/\\]subagents[/\\]/.test(transcriptPath)) return "main";
256
+ const metaPath = transcriptPath.replace(/\.jsonl$/, ".meta.json");
257
+ if (existsSync(metaPath)) {
258
+ const meta: unknown = JSON.parse(readFileSync(metaPath, "utf-8"));
259
+ const agentType =
260
+ typeof meta === "object" && meta !== null
261
+ ? (meta as Record<string, unknown>).agentType
262
+ : undefined;
263
+ return typeof agentType === "string" ? agentType : "subagent";
264
+ }
265
+ return "subagent";
266
+ } catch {
267
+ return "main";
268
+ }
269
+ }
270
+
271
+ async function processSkillToolUse(
272
+ payload: PostToolUsePayload,
273
+ _logPath: string,
274
+ canonicalLogPath: string,
275
+ promptStatePath?: string,
276
+ ): Promise<SkillUsageRecord | null> {
277
+ const rawSkill = payload.tool_input?.skill;
278
+ const skillName = typeof rawSkill === "string" ? rawSkill : null;
279
+ if (!skillName) return null;
280
+
281
+ const transcriptPath = payload.transcript_path ?? "";
282
+ const sessionId = payload.session_id ?? "unknown";
283
+
284
+ const query = getLastUserMessage(transcriptPath);
285
+ if (!query) return null;
286
+
287
+ const invocationType = classifyInvocationType(query, skillName);
288
+ const invocationIndex = countSkillToolInvocations(transcriptPath, skillName) - 1;
289
+
290
+ const record: SkillUsageRecord = {
291
+ timestamp: new Date().toISOString(),
292
+ session_id: sessionId,
293
+ skill_name: skillName,
294
+ skill_path: "",
295
+ query,
296
+ triggered: true,
297
+ invocation_type: invocationType,
298
+ source: "claude_code",
299
+ };
300
+
301
+ const baseInput: CanonicalBaseInput = {
302
+ platform: "claude_code",
303
+ capture_mode: "hook",
304
+ source_session_kind: "interactive",
305
+ session_id: sessionId,
306
+ raw_source_ref: {
307
+ path: transcriptPath || undefined,
308
+ event_type: "PostToolUse",
309
+ },
310
+ };
311
+ const latestPrompt = getLatestPromptIdentity(sessionId, promptStatePath, canonicalLogPath);
312
+ const promptId =
313
+ latestPrompt.last_actionable_prompt_id ??
314
+ latestPrompt.last_prompt_id ??
315
+ derivePromptId(sessionId, 0);
316
+ const { invocation_mode, confidence } = deriveInvocationMode({
317
+ hook_invocation_type: invocationType,
318
+ });
319
+ // Detect if this invocation is from a subagent
320
+ const agentType = detectAgentType(transcriptPath);
321
+
322
+ const canonical = buildCanonicalSkillInvocation({
323
+ ...baseInput,
324
+ skill_invocation_id: deriveSkillInvocationId(
325
+ sessionId,
326
+ skillName,
327
+ Math.max(invocationIndex, 0),
328
+ ),
329
+ occurred_at: record.timestamp,
330
+ matched_prompt_id: promptId,
331
+ skill_name: skillName,
332
+ skill_path: "",
333
+ invocation_mode,
334
+ triggered: true,
335
+ confidence,
336
+ tool_name: payload.tool_name,
337
+ agent_type: agentType,
338
+ });
339
+
340
+ // Write unified record to skill_invocations (replaces separate writeSkillUsageToDb call)
341
+ try {
342
+ const { writeSkillCheckToDb } = await import("../localdb/direct-write.js");
343
+ writeSkillCheckToDb({
344
+ ...canonical,
345
+ query: record.query,
346
+ skill_path: record.skill_path,
347
+ skill_scope: record.skill_scope,
348
+ source: record.source,
349
+ });
350
+ } catch {
351
+ /* hooks must never block */
352
+ }
353
+
175
354
  appendCanonicalRecord(canonical, canonicalLogPath);
176
355
 
177
356
  return record;
@@ -181,7 +360,7 @@ export function processToolUse(
181
360
  if (import.meta.main) {
182
361
  try {
183
362
  const payload: PostToolUsePayload = JSON.parse(await Bun.stdin.text());
184
- processToolUse(payload);
363
+ await processToolUse(payload);
185
364
  } catch {
186
365
  // silent — hooks must never block Claude
187
366
  }
@@ -21,8 +21,10 @@
21
21
  * selftune workflows — Discover and manage multi-skill workflows
22
22
  * selftune quickstart — Guided onboarding: init, ingest, status, and suggestions
23
23
  * selftune repair-skill-usage — Rebuild trustworthy skill usage from transcripts
24
+ * selftune export — Export SQLite data to JSONL files
24
25
  * selftune export-canonical — Export canonical telemetry for downstream ingestion
25
26
  * selftune telemetry — Manage anonymous usage analytics (status, enable, disable)
27
+ * selftune alpha <subcommand> — Alpha program management (upload)
26
28
  * selftune hook <name> — Run a hook by name (prompt-log, session-stop, etc.)
27
29
  */
28
30
 
@@ -53,7 +55,9 @@ Commands:
53
55
  workflows Discover and manage multi-skill workflows
54
56
  quickstart Guided onboarding: init, ingest, status, and suggestions
55
57
  repair-skill-usage Rebuild trustworthy skill usage from transcripts
58
+ export Export SQLite data to JSONL files
56
59
  export-canonical Export canonical telemetry for downstream ingestion
60
+ alpha <subcommand> Alpha program management (upload)
57
61
  telemetry Manage anonymous usage analytics (status, enable, disable)
58
62
  hook <name> Run a hook by name (prompt-log, session-stop, etc.)
59
63
 
@@ -68,6 +72,12 @@ if (command && command !== "--help" && command !== "-h") {
68
72
  .catch(() => {});
69
73
  }
70
74
 
75
+ // Auto-update check (skip for hooks — they must be fast — and --help)
76
+ if (command && command !== "hook" && command !== "--help" && command !== "-h") {
77
+ const { autoUpdate } = await import("./auto-update.js");
78
+ await autoUpdate();
79
+ }
80
+
71
81
  if (!command) {
72
82
  // Show status by default — same as `selftune status`
73
83
  const { cliMain: statusMain } = await import("./status.js");
@@ -263,7 +273,6 @@ Run 'selftune eval <action> --help' for action-specific options.`);
263
273
  process.exit(0);
264
274
  }
265
275
  const { parseArgs } = await import("node:util");
266
- const { readJsonl } = await import("./utils/jsonl.js");
267
276
  const { TELEMETRY_LOG } = await import("./constants.js");
268
277
  const { analyzeComposability } = await import("./eval/composability.js");
269
278
  let values: ReturnType<typeof parseArgs>["values"];
@@ -287,7 +296,22 @@ Run 'selftune eval <action> --help' for action-specific options.`);
287
296
  process.exit(1);
288
297
  }
289
298
  const logPath = values["telemetry-log"] ?? TELEMETRY_LOG;
290
- const telemetry = readJsonl(logPath);
299
+ let telemetry: unknown[];
300
+ if (logPath === TELEMETRY_LOG) {
301
+ try {
302
+ const { getDb } = await import("./localdb/db.js");
303
+ const { querySessionTelemetry } = await import("./localdb/queries.js");
304
+ const db = getDb();
305
+ telemetry = querySessionTelemetry(db);
306
+ } catch {
307
+ // DB unavailable — fall back to JSONL
308
+ const { readJsonl } = await import("./utils/jsonl.js");
309
+ telemetry = readJsonl(logPath);
310
+ }
311
+ } else {
312
+ const { readJsonl } = await import("./utils/jsonl.js");
313
+ telemetry = readJsonl(logPath);
314
+ }
291
315
  const rawWindow = values.window as string | undefined;
292
316
  if (rawWindow !== undefined && !/^[1-9]\d*$/.test(rawWindow)) {
293
317
  console.error("Invalid --window value. Use a positive integer number of days.");
@@ -458,6 +482,67 @@ Run 'selftune cron <subcommand> --help' for subcommand-specific options.`);
458
482
  cliMain();
459
483
  break;
460
484
  }
485
+ case "export": {
486
+ const { parseArgs } = await import("node:util");
487
+ let values: ReturnType<typeof parseArgs>["values"];
488
+ let positionals: string[];
489
+ try {
490
+ ({ values, positionals } = parseArgs({
491
+ options: {
492
+ output: { type: "string", short: "o" },
493
+ since: { type: "string" },
494
+ help: { type: "boolean", short: "h" },
495
+ },
496
+ allowPositionals: true,
497
+ strict: true,
498
+ }));
499
+ } catch (error) {
500
+ const message = error instanceof Error ? error.message : String(error);
501
+ console.error(`Invalid arguments: ${message}`);
502
+ console.error("Run 'selftune export --help' for usage.");
503
+ process.exit(1);
504
+ }
505
+ if (values.help) {
506
+ console.log(`selftune export — Export SQLite data to JSONL files
507
+
508
+ Usage:
509
+ selftune export [tables...] [options]
510
+
511
+ Tables (default: all):
512
+ telemetry Session telemetry records
513
+ skills Skill usage records
514
+ queries Query log entries
515
+ audit Evolution audit trail
516
+ evidence Evolution evidence trail
517
+ signals Improvement signals
518
+ orchestrate Orchestrate run log
519
+
520
+ Options:
521
+ -o, --output <dir> Output directory (default: current directory)
522
+ --since <date> Only export records after this date (ISO 8601)
523
+ -h, --help Show this help`);
524
+ process.exit(0);
525
+ }
526
+ const { exportToJsonl } = await import("./export.js");
527
+ const outputDir = (values.output as string | undefined) ?? process.cwd();
528
+ const since = values.since as string | undefined;
529
+ const tables = positionals.length > 0 ? positionals : undefined;
530
+ try {
531
+ const result = exportToJsonl({ outputDir, since, tables });
532
+ console.log(
533
+ `Exported ${result.records} records to ${result.files.length} files in ${outputDir}`,
534
+ );
535
+ for (const file of result.files) {
536
+ console.log(` ${file}`);
537
+ }
538
+ } catch (err: unknown) {
539
+ const message = err instanceof Error ? err.message : String(err);
540
+ console.error(`Export failed: ${message}`);
541
+ console.error("Ensure the SQLite database exists. Run 'selftune sync' first if needed.");
542
+ process.exit(1);
543
+ }
544
+ break;
545
+ }
461
546
  case "export-canonical": {
462
547
  const { cliMain } = await import("./canonical-export.js");
463
548
  cliMain();
@@ -468,6 +553,193 @@ Run 'selftune cron <subcommand> --help' for subcommand-specific options.`);
468
553
  await cliMain();
469
554
  break;
470
555
  }
556
+ case "alpha": {
557
+ const sub = process.argv[2];
558
+ if (!sub || sub === "--help" || sub === "-h") {
559
+ console.log(`selftune alpha — Alpha program management
560
+
561
+ Usage:
562
+ selftune alpha <subcommand> [options]
563
+
564
+ Subcommands:
565
+ upload Run a manual alpha data upload cycle
566
+ relink Re-authenticate with the cloud (revokes old key, issues new one)
567
+
568
+ Run 'selftune alpha <subcommand> --help' for subcommand-specific options.`);
569
+ process.exit(0);
570
+ }
571
+ process.argv = [process.argv[0], process.argv[1], ...process.argv.slice(3)];
572
+ switch (sub) {
573
+ case "upload": {
574
+ const { parseArgs } = await import("node:util");
575
+ let values: ReturnType<typeof parseArgs>["values"];
576
+ try {
577
+ ({ values } = parseArgs({
578
+ options: {
579
+ "dry-run": { type: "boolean", default: false },
580
+ help: { type: "boolean", short: "h", default: false },
581
+ },
582
+ strict: true,
583
+ }));
584
+ } catch (error) {
585
+ const message = error instanceof Error ? error.message : String(error);
586
+ console.error(`Invalid arguments: ${message}`);
587
+ process.exit(1);
588
+ }
589
+ if (values.help) {
590
+ console.log(`selftune alpha upload — Run a manual alpha data upload cycle
591
+
592
+ Usage:
593
+ selftune alpha upload [--dry-run]
594
+
595
+ Options:
596
+ --dry-run Log what would be uploaded without sending
597
+ -h, --help Show this help message
598
+
599
+ Output:
600
+ JSON summary: { enrolled, prepared, sent, failed, skipped, guidance? }`);
601
+ process.exit(0);
602
+ }
603
+
604
+ const { SELFTUNE_CONFIG_PATH } = await import("./constants.js");
605
+ const { getAlphaGuidance } = await import("./agent-guidance.js");
606
+ const { readAlphaIdentity } = await import("./alpha-identity.js");
607
+ const { getDb } = await import("./localdb/db.js");
608
+ const { runUploadCycle } = await import("./alpha-upload/index.js");
609
+ const { getSelftuneVersion, readConfiguredAgentType } = await import(
610
+ "./utils/selftune-meta.js"
611
+ );
612
+
613
+ const identity = readAlphaIdentity(SELFTUNE_CONFIG_PATH);
614
+ if (!identity?.enrolled) {
615
+ const guidance = getAlphaGuidance(identity);
616
+ console.log(
617
+ JSON.stringify(
618
+ {
619
+ enrolled: false,
620
+ prepared: 0,
621
+ sent: 0,
622
+ failed: 0,
623
+ skipped: 0,
624
+ guidance,
625
+ },
626
+ null,
627
+ 2,
628
+ ),
629
+ );
630
+ console.error(`[alpha upload] ${guidance.message}`);
631
+ console.error(`[alpha upload] Next: ${guidance.next_command}`);
632
+ process.exit(1);
633
+ }
634
+
635
+ if (!identity.user_id?.trim() || !identity.api_key?.trim()) {
636
+ const guidance = getAlphaGuidance(identity);
637
+ console.log(
638
+ JSON.stringify(
639
+ {
640
+ enrolled: true,
641
+ prepared: 0,
642
+ sent: 0,
643
+ failed: 0,
644
+ skipped: 0,
645
+ guidance,
646
+ },
647
+ null,
648
+ 2,
649
+ ),
650
+ );
651
+ console.error(`[alpha upload] ${guidance.message}`);
652
+ console.error(`[alpha upload] Next: ${guidance.next_command}`);
653
+ process.exit(1);
654
+ }
655
+
656
+ const db = getDb();
657
+
658
+ const result = await runUploadCycle(db, {
659
+ enrolled: true,
660
+ userId: identity.user_id,
661
+ agentType: readConfiguredAgentType(SELFTUNE_CONFIG_PATH, "unknown"),
662
+ selftuneVersion: getSelftuneVersion(),
663
+ dryRun: values["dry-run"] ?? false,
664
+ apiKey: identity.api_key,
665
+ });
666
+
667
+ console.log(JSON.stringify(result, null, 2));
668
+ process.exit(result.failed > 0 ? 1 : 0);
669
+ break;
670
+ }
671
+ case "relink": {
672
+ const { SELFTUNE_CONFIG_PATH } = await import("./constants.js");
673
+ const { readAlphaIdentity, writeAlphaIdentity, generateUserId } = await import(
674
+ "./alpha-identity.js"
675
+ );
676
+ const { requestDeviceCode, pollDeviceCode } = await import("./auth/device-code.js");
677
+ const { chmodSync } = await import("node:fs");
678
+
679
+ const existingIdentity = readAlphaIdentity(SELFTUNE_CONFIG_PATH);
680
+ process.stderr.write("[alpha relink] Starting device-code authentication flow...\n");
681
+
682
+ const grant = await requestDeviceCode();
683
+
684
+ console.log(
685
+ JSON.stringify({
686
+ level: "info",
687
+ code: "device_code_issued",
688
+ verification_url: grant.verification_url,
689
+ user_code: grant.user_code,
690
+ expires_in: grant.expires_in,
691
+ message: `Open ${grant.verification_url} and enter code: ${grant.user_code}`,
692
+ }),
693
+ );
694
+
695
+ // Try to open browser
696
+ try {
697
+ const url = `${grant.verification_url}?code=${grant.user_code}`;
698
+ Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" });
699
+ process.stderr.write("[alpha relink] Browser opened. Waiting for approval...\n");
700
+ } catch {
701
+ process.stderr.write(
702
+ "[alpha relink] Could not open browser. Visit the URL above manually.\n",
703
+ );
704
+ }
705
+
706
+ process.stderr.write("[alpha relink] Polling");
707
+ const result = await pollDeviceCode(grant.device_code, grant.interval, grant.expires_in);
708
+ process.stderr.write("\n[alpha relink] Approved!\n");
709
+
710
+ const updatedIdentity = {
711
+ enrolled: true,
712
+ user_id: existingIdentity?.user_id ?? generateUserId(),
713
+ cloud_user_id: result.cloud_user_id,
714
+ cloud_org_id: result.org_id,
715
+ email: existingIdentity?.email,
716
+ display_name: existingIdentity?.display_name,
717
+ consent_timestamp: new Date().toISOString(),
718
+ api_key: result.api_key,
719
+ };
720
+
721
+ writeAlphaIdentity(SELFTUNE_CONFIG_PATH, updatedIdentity);
722
+ chmodSync(SELFTUNE_CONFIG_PATH, 0o600);
723
+
724
+ console.log(
725
+ JSON.stringify({
726
+ level: "info",
727
+ code: "alpha_relinked",
728
+ replaced_existing_key: Boolean(existingIdentity?.api_key),
729
+ cloud_user_id: result.cloud_user_id,
730
+ message: "Successfully relinked. Old key revoked by cloud during approval.",
731
+ }),
732
+ );
733
+ break;
734
+ }
735
+ default:
736
+ console.error(
737
+ `Unknown alpha subcommand: ${sub}\nRun 'selftune alpha --help' for available subcommands.`,
738
+ );
739
+ process.exit(1);
740
+ }
741
+ break;
742
+ }
471
743
  case "telemetry": {
472
744
  const { cliMain } = await import("./analytics.js");
473
745
  await cliMain();