selftune 0.2.2 → 0.2.5

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 (53) hide show
  1. package/README.md +11 -0
  2. package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +1 -0
  3. package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +15 -0
  4. package/apps/local-dashboard/dist/assets/vendor-ui-r2k_Ku_V.js +346 -0
  5. package/apps/local-dashboard/dist/index.html +3 -3
  6. package/cli/selftune/analytics.ts +354 -0
  7. package/cli/selftune/badge/badge.ts +2 -2
  8. package/cli/selftune/dashboard-server.ts +3 -3
  9. package/cli/selftune/evolution/evolve-body.ts +1 -1
  10. package/cli/selftune/evolution/evolve.ts +1 -1
  11. package/cli/selftune/index.ts +15 -1
  12. package/cli/selftune/init.ts +5 -1
  13. package/cli/selftune/observability.ts +63 -2
  14. package/cli/selftune/orchestrate.ts +1 -1
  15. package/cli/selftune/quickstart.ts +1 -1
  16. package/cli/selftune/status.ts +2 -2
  17. package/cli/selftune/types.ts +1 -0
  18. package/cli/selftune/utils/llm-call.ts +2 -1
  19. package/package.json +5 -3
  20. package/packages/ui/README.md +113 -0
  21. package/packages/ui/index.ts +10 -0
  22. package/packages/ui/package.json +62 -0
  23. package/packages/ui/src/components/ActivityTimeline.tsx +171 -0
  24. package/packages/ui/src/components/EvidenceViewer.tsx +718 -0
  25. package/packages/ui/src/components/EvolutionTimeline.tsx +252 -0
  26. package/packages/ui/src/components/InfoTip.tsx +19 -0
  27. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +164 -0
  28. package/packages/ui/src/components/index.ts +7 -0
  29. package/packages/ui/src/components/section-cards.tsx +155 -0
  30. package/packages/ui/src/components/skill-health-grid.tsx +686 -0
  31. package/packages/ui/src/lib/constants.tsx +43 -0
  32. package/packages/ui/src/lib/format.ts +37 -0
  33. package/packages/ui/src/lib/index.ts +3 -0
  34. package/packages/ui/src/lib/utils.ts +6 -0
  35. package/packages/ui/src/primitives/badge.tsx +52 -0
  36. package/packages/ui/src/primitives/button.tsx +58 -0
  37. package/packages/ui/src/primitives/card.tsx +103 -0
  38. package/packages/ui/src/primitives/checkbox.tsx +27 -0
  39. package/packages/ui/src/primitives/collapsible.tsx +7 -0
  40. package/packages/ui/src/primitives/dropdown-menu.tsx +266 -0
  41. package/packages/ui/src/primitives/index.ts +55 -0
  42. package/packages/ui/src/primitives/label.tsx +20 -0
  43. package/packages/ui/src/primitives/select.tsx +197 -0
  44. package/packages/ui/src/primitives/table.tsx +114 -0
  45. package/packages/ui/src/primitives/tabs.tsx +82 -0
  46. package/packages/ui/src/primitives/tooltip.tsx +64 -0
  47. package/packages/ui/src/types.ts +87 -0
  48. package/packages/ui/tsconfig.json +17 -0
  49. package/skill/SKILL.md +3 -0
  50. package/skill/Workflows/Telemetry.md +59 -0
  51. package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +0 -15
  52. package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +0 -1
  53. package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +0 -346
@@ -5,11 +5,11 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>selftune — Dashboard</title>
7
7
  <link rel="icon" type="image/png" href="/favicon.png" />
8
- <script type="module" crossorigin src="/assets/index-C4EOTFZ2.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-axE4kz3Q.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="/assets/vendor-react-U7zYD9Rg.js">
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-ui-D7_zX_qy.js">
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-ui-r2k_Ku_V.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/vendor-table-B7VF2Ipl.js">
12
- <link rel="stylesheet" crossorigin href="/assets/index-bl-Webyd.css">
12
+ <link rel="stylesheet" crossorigin href="/assets/index-C75H1Q3n.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -0,0 +1,354 @@
1
+ /**
2
+ * selftune anonymous usage analytics.
3
+ *
4
+ * Collects anonymous, non-identifying usage data to help prioritize
5
+ * features and understand how selftune is used in the wild.
6
+ *
7
+ * Privacy guarantees:
8
+ * - No PII: no usernames, emails, IPs, file paths, or repo names
9
+ * - No session IDs; events are linkable by anonymous_id and sent_at
10
+ * - Anonymous machine ID: random, persisted locally (not derived from any user data)
11
+ * - Fire-and-forget: never blocks CLI execution
12
+ * - Easy opt-out: env var or config flag
13
+ *
14
+ * Opt out:
15
+ * - Set SELFTUNE_NO_ANALYTICS=1 in your environment
16
+ * - Run `selftune telemetry disable`
17
+ * - Set "analytics_disabled": true in ~/.selftune/config.json
18
+ */
19
+
20
+ import { randomBytes } from "node:crypto";
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { arch, platform, release } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ import { SELFTUNE_CONFIG_DIR, SELFTUNE_CONFIG_PATH } from "./constants.js";
26
+ import type { SelftuneConfig } from "./types.js";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Configuration
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const ANALYTICS_ENDPOINT =
33
+ process.env.SELFTUNE_ANALYTICS_ENDPOINT ?? "https://telemetry.selftune.dev/v1/events";
34
+
35
+ function getVersion(): string {
36
+ try {
37
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, "../../package.json"), "utf-8"));
38
+ return pkg.version ?? "unknown";
39
+ } catch {
40
+ return "unknown";
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Cached config — read once per process, shared across all functions
46
+ // ---------------------------------------------------------------------------
47
+
48
+ let cachedConfig: SelftuneConfig | null | undefined;
49
+
50
+ function loadConfig(): SelftuneConfig | null {
51
+ if (cachedConfig !== undefined) return cachedConfig;
52
+ try {
53
+ if (existsSync(SELFTUNE_CONFIG_PATH)) {
54
+ cachedConfig = JSON.parse(readFileSync(SELFTUNE_CONFIG_PATH, "utf-8")) as SelftuneConfig;
55
+ } else {
56
+ cachedConfig = null;
57
+ }
58
+ } catch {
59
+ cachedConfig = null;
60
+ }
61
+ return cachedConfig;
62
+ }
63
+
64
+ /** Invalidate cached config (used after writes). */
65
+ function invalidateConfigCache(): void {
66
+ cachedConfig = undefined;
67
+ }
68
+
69
+ /** Reset all cached state. Exported for test isolation only. */
70
+ export function resetAnalyticsState(): void {
71
+ cachedConfig = undefined;
72
+ cachedAnonymousId = undefined;
73
+ cachedOsContext = undefined;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Persisted anonymous ID — random, non-reversible, stable across runs
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const ANONYMOUS_ID_PATH = join(SELFTUNE_CONFIG_DIR, ".anonymous_id");
81
+ let cachedAnonymousId: string | undefined;
82
+
83
+ /**
84
+ * Get or create a random anonymous machine ID.
85
+ * Generated once via crypto.randomBytes and persisted to disk.
86
+ * Cannot be reversed to recover any user/machine information.
87
+ * Result is memoized for the process lifetime.
88
+ */
89
+ export function getAnonymousId(): string {
90
+ if (cachedAnonymousId) return cachedAnonymousId;
91
+ try {
92
+ if (existsSync(ANONYMOUS_ID_PATH)) {
93
+ const stored = readFileSync(ANONYMOUS_ID_PATH, "utf-8").trim();
94
+ if (/^[a-f0-9]{16}$/.test(stored)) {
95
+ cachedAnonymousId = stored;
96
+ return stored;
97
+ }
98
+ }
99
+ } catch {
100
+ // fall through to generate
101
+ }
102
+ const id = randomBytes(8).toString("hex"); // 16 hex chars
103
+ try {
104
+ mkdirSync(SELFTUNE_CONFIG_DIR, { recursive: true });
105
+ writeFileSync(ANONYMOUS_ID_PATH, id, "utf-8");
106
+ } catch {
107
+ // non-fatal — use ephemeral ID for this process
108
+ }
109
+ cachedAnonymousId = id;
110
+ return id;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Cached OS context — doesn't change within a process
115
+ // ---------------------------------------------------------------------------
116
+
117
+ let cachedOsContext: { os: string; os_release: string; arch: string } | undefined;
118
+
119
+ function getOsContext(): { os: string; os_release: string; arch: string } {
120
+ if (cachedOsContext) return cachedOsContext;
121
+ cachedOsContext = { os: platform(), os_release: release(), arch: arch() };
122
+ return cachedOsContext;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Analytics gate
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Check whether analytics is enabled.
131
+ * Returns false if:
132
+ * - SELFTUNE_NO_ANALYTICS env var is set to any truthy value
133
+ * - Config file has analytics_disabled: true
134
+ * - CI environment detected (CI=true)
135
+ */
136
+ export function isAnalyticsEnabled(): boolean {
137
+ // Env var override (highest priority)
138
+ const envDisabled = process.env.SELFTUNE_NO_ANALYTICS;
139
+ if (envDisabled && envDisabled !== "0" && envDisabled !== "false") {
140
+ return false;
141
+ }
142
+
143
+ // CI detection — don't inflate analytics from CI pipelines
144
+ if (process.env.CI === "true" || process.env.CI === "1") {
145
+ return false;
146
+ }
147
+
148
+ // Config file check (uses cached read — no redundant I/O)
149
+ const config = loadConfig();
150
+ if (config?.analytics_disabled) {
151
+ return false;
152
+ }
153
+
154
+ return true;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Event tracking
159
+ // ---------------------------------------------------------------------------
160
+
161
+ export interface AnalyticsEvent {
162
+ event: string;
163
+ properties: Record<string, string | number | boolean>;
164
+ context: {
165
+ anonymous_id: string;
166
+ os: string;
167
+ os_release: string;
168
+ arch: string;
169
+ selftune_version: string;
170
+ node_version: string;
171
+ agent_type: string;
172
+ };
173
+ sent_at: string;
174
+ }
175
+
176
+ /**
177
+ * Build an analytics event payload.
178
+ * Exported for testing — does NOT send the event.
179
+ */
180
+ export function buildEvent(
181
+ eventName: string,
182
+ properties: Record<string, string | number | boolean> = {},
183
+ ): AnalyticsEvent {
184
+ const config = loadConfig();
185
+ const agentType: SelftuneConfig["agent_type"] = config?.agent_type ?? "unknown";
186
+ const osCtx = getOsContext();
187
+
188
+ return {
189
+ event: eventName,
190
+ properties,
191
+ context: {
192
+ anonymous_id: getAnonymousId(),
193
+ ...osCtx,
194
+ selftune_version: getVersion(),
195
+ node_version: process.version,
196
+ agent_type: agentType,
197
+ },
198
+ sent_at: new Date().toISOString(),
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Track an analytics event. Fire-and-forget — never blocks, never throws.
204
+ *
205
+ * @param eventName - Event name (e.g., "command_run")
206
+ * @param properties - Event properties (no PII allowed)
207
+ * @param options - Override endpoint or fetch for testing
208
+ */
209
+ export function trackEvent(
210
+ eventName: string,
211
+ properties: Record<string, string | number | boolean> = {},
212
+ options?: { endpoint?: string; fetchFn?: typeof fetch },
213
+ ): void {
214
+ if (!isAnalyticsEnabled()) return;
215
+
216
+ const event = buildEvent(eventName, properties);
217
+ const endpoint = options?.endpoint ?? ANALYTICS_ENDPOINT;
218
+ const fetchFn = options?.fetchFn ?? fetch;
219
+
220
+ // Fire and forget — intentionally not awaited.
221
+ // Wrapped in try + Promise.resolve to catch both sync throws and async rejections.
222
+ try {
223
+ Promise.resolve(
224
+ fetchFn(endpoint, {
225
+ method: "POST",
226
+ headers: { "Content-Type": "application/json" },
227
+ body: JSON.stringify(event),
228
+ signal: AbortSignal.timeout(3000), // 3s timeout — don't hang
229
+ }),
230
+ ).catch(() => {
231
+ // Silently ignore — analytics should never break the CLI
232
+ });
233
+ } catch {
234
+ // Silently ignore sync throws from fetchFn
235
+ }
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // CLI: selftune telemetry [status|enable|disable]
240
+ // ---------------------------------------------------------------------------
241
+
242
+ function writeConfigField(field: keyof SelftuneConfig, value: unknown): void {
243
+ let config: Record<string, unknown> = {};
244
+ try {
245
+ if (existsSync(SELFTUNE_CONFIG_PATH)) {
246
+ config = JSON.parse(readFileSync(SELFTUNE_CONFIG_PATH, "utf-8"));
247
+ }
248
+ } catch {
249
+ // start fresh
250
+ }
251
+ config[field] = value;
252
+ mkdirSync(SELFTUNE_CONFIG_DIR, { recursive: true });
253
+ writeFileSync(SELFTUNE_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8");
254
+ invalidateConfigCache();
255
+ }
256
+
257
+ export async function cliMain(): Promise<void> {
258
+ const sub = process.argv[2];
259
+
260
+ if (sub === "--help" || sub === "-h") {
261
+ console.log(`selftune telemetry — Manage anonymous usage analytics
262
+
263
+ Usage:
264
+ selftune telemetry Show current telemetry status
265
+ selftune telemetry status Show current telemetry status
266
+ selftune telemetry enable Enable anonymous usage analytics
267
+ selftune telemetry disable Disable anonymous usage analytics
268
+
269
+ Environment:
270
+ SELFTUNE_NO_ANALYTICS=1 Disable analytics via env var
271
+
272
+ selftune collects anonymous, non-identifying usage data to help
273
+ prioritize features. No PII is ever collected. See:
274
+ https://github.com/selftune-dev/selftune#telemetry`);
275
+ process.exit(0);
276
+ }
277
+
278
+ switch (sub) {
279
+ case "disable": {
280
+ try {
281
+ writeConfigField("analytics_disabled", true);
282
+ } catch {
283
+ console.error(
284
+ "Failed to disable telemetry: cannot write ~/.selftune/config.json. " +
285
+ "Try checking file permissions, or set SELFTUNE_NO_ANALYTICS=1.",
286
+ );
287
+ process.exit(1);
288
+ }
289
+ console.log("Telemetry disabled. No anonymous usage data will be sent.");
290
+ console.log("You can re-enable with: selftune telemetry enable");
291
+ break;
292
+ }
293
+ case "enable": {
294
+ try {
295
+ writeConfigField("analytics_disabled", false);
296
+ } catch {
297
+ console.error(
298
+ "Failed to enable telemetry: cannot write ~/.selftune/config.json. " +
299
+ "Try checking file permissions.",
300
+ );
301
+ process.exit(1);
302
+ }
303
+ console.log("Telemetry enabled. Anonymous usage data will be sent.");
304
+ console.log("Disable anytime with: selftune telemetry disable");
305
+ console.log("Or set SELFTUNE_NO_ANALYTICS=1 in your environment.");
306
+ break;
307
+ }
308
+ case "status":
309
+ case undefined: {
310
+ const enabled = isAnalyticsEnabled();
311
+ const config = loadConfig();
312
+ const envDisabled = process.env.SELFTUNE_NO_ANALYTICS;
313
+ const configDisabled = config?.analytics_disabled ?? false;
314
+
315
+ console.log(`Telemetry: ${enabled ? "enabled" : "disabled"}`);
316
+ if (envDisabled && envDisabled !== "0" && envDisabled !== "false") {
317
+ console.log(" Disabled via: SELFTUNE_NO_ANALYTICS environment variable");
318
+ }
319
+ if (configDisabled) {
320
+ console.log(" Disabled via: config file (~/.selftune/config.json)");
321
+ }
322
+ if (process.env.CI === "true" || process.env.CI === "1") {
323
+ console.log(" Disabled via: CI environment detected");
324
+ }
325
+ if (enabled) {
326
+ console.log(` Anonymous ID: ${getAnonymousId()}`);
327
+ console.log(` Endpoint: ${ANALYTICS_ENDPOINT}`);
328
+ }
329
+ console.log("\nTo opt out: selftune telemetry disable");
330
+ console.log("Or set SELFTUNE_NO_ANALYTICS=1 in your environment.");
331
+ break;
332
+ }
333
+ default:
334
+ console.error(
335
+ `Unknown telemetry subcommand: ${sub}\nRun 'selftune telemetry --help' for usage.`,
336
+ );
337
+ process.exit(1);
338
+ }
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Telemetry disclosure notice (for init flow)
343
+ // ---------------------------------------------------------------------------
344
+
345
+ export const TELEMETRY_NOTICE = `
346
+ selftune collects anonymous usage analytics to improve the tool.
347
+ No personal information is ever collected — only command names,
348
+ OS/arch, and selftune version.
349
+
350
+ To opt out at any time:
351
+ selftune telemetry disable
352
+ # or
353
+ export SELFTUNE_NO_ANALYTICS=1
354
+ `;
@@ -30,7 +30,7 @@ Options:
30
30
 
31
31
  const VALID_FORMATS = new Set<BadgeFormat>(["svg", "markdown", "url"]);
32
32
 
33
- export function cliMain(): void {
33
+ export async function cliMain(): Promise<void> {
34
34
  const { values } = parseArgs({
35
35
  args: process.argv.slice(2),
36
36
  options: {
@@ -71,7 +71,7 @@ export function cliMain(): void {
71
71
  const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
72
72
 
73
73
  // Run doctor for system health
74
- const doctorResult = doctor();
74
+ const doctorResult = await doctor();
75
75
 
76
76
  // Compute status
77
77
  const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
@@ -100,12 +100,12 @@ const MIME_TYPES: Record<string, string> = {
100
100
  ".ico": "image/x-icon",
101
101
  };
102
102
 
103
- function computeStatusFromLogs(): StatusResult {
103
+ async function computeStatusFromLogs(): Promise<StatusResult> {
104
104
  const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
105
105
  const skillRecords = readEffectiveSkillUsageRecords();
106
106
  const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
107
107
  const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
108
- const doctorResult = doctor();
108
+ const doctorResult = await doctor();
109
109
  return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
110
110
  }
111
111
 
@@ -531,7 +531,7 @@ export async function startDashboardServer(
531
531
 
532
532
  // ---- GET /api/v2/doctor ---- System health diagnostics
533
533
  if (url.pathname === "/api/v2/doctor" && req.method === "GET") {
534
- const result = doctor();
534
+ const result = await doctor();
535
535
  return Response.json(result, { headers: corsHeaders() });
536
536
  }
537
537
 
@@ -410,7 +410,7 @@ export async function evolveBody(
410
410
  };
411
411
  }
412
412
 
413
- if (lastProposal && lastValidation && lastValidation.improved) {
413
+ if (lastProposal && lastValidation?.improved) {
414
414
  // Deploy: write updated SKILL.md
415
415
  if (target === "routing") {
416
416
  const updatedContent = replaceSection(
@@ -841,7 +841,7 @@ export async function evolve(
841
841
  // -----------------------------------------------------------------------
842
842
  // Step 15: Update evolution memory
843
843
  // -----------------------------------------------------------------------
844
- const wasDeployed = lastProposal !== null && lastValidation !== null && lastValidation.improved;
844
+ const wasDeployed = lastProposal && lastValidation?.improved;
845
845
  const evolveResult: EvolveResult = withStats({
846
846
  proposal: lastProposal,
847
847
  validation: lastValidation,
@@ -22,6 +22,7 @@
22
22
  * selftune quickstart — Guided onboarding: init, ingest, status, and suggestions
23
23
  * selftune repair-skill-usage — Rebuild trustworthy skill usage from transcripts
24
24
  * selftune export-canonical — Export canonical telemetry for downstream ingestion
25
+ * selftune telemetry — Manage anonymous usage analytics (status, enable, disable)
25
26
  * selftune hook <name> — Run a hook by name (prompt-log, session-stop, etc.)
26
27
  */
27
28
 
@@ -53,12 +54,20 @@ Commands:
53
54
  quickstart Guided onboarding: init, ingest, status, and suggestions
54
55
  repair-skill-usage Rebuild trustworthy skill usage from transcripts
55
56
  export-canonical Export canonical telemetry for downstream ingestion
57
+ telemetry Manage anonymous usage analytics (status, enable, disable)
56
58
  hook <name> Run a hook by name (prompt-log, session-stop, etc.)
57
59
 
58
60
  Run 'selftune <command> --help' for command-specific options.`);
59
61
  process.exit(0);
60
62
  }
61
63
 
64
+ // Track command usage (lazy import — avoids loading crypto/os on --help or no-op paths)
65
+ if (command && command !== "--help" && command !== "-h") {
66
+ import("./analytics.js")
67
+ .then(({ trackEvent }) => trackEvent("command_run", { command }))
68
+ .catch(() => {});
69
+ }
70
+
62
71
  if (!command) {
63
72
  // Show status by default — same as `selftune status`
64
73
  const { cliMain: statusMain } = await import("./status.js");
@@ -317,7 +326,7 @@ Run 'selftune eval <action> --help' for action-specific options.`);
317
326
  }
318
327
  case "doctor": {
319
328
  const { doctor } = await import("./observability.js");
320
- const result = doctor();
329
+ const result = await doctor();
321
330
  console.log(JSON.stringify(result, null, 2));
322
331
  process.exit(result.healthy ? 0 : 1);
323
332
  break;
@@ -459,6 +468,11 @@ Run 'selftune cron <subcommand> --help' for subcommand-specific options.`);
459
468
  await cliMain();
460
469
  break;
461
470
  }
471
+ case "telemetry": {
472
+ const { cliMain } = await import("./analytics.js");
473
+ await cliMain();
474
+ break;
475
+ }
462
476
  case "hook": {
463
477
  // Dispatch to the appropriate hook file by name.
464
478
  const hookName = process.argv[2]; // argv was shifted above
@@ -24,6 +24,7 @@ import { dirname, join, resolve } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { parseArgs } from "node:util";
26
26
 
27
+ import { TELEMETRY_NOTICE } from "./analytics.js";
27
28
  import { CLAUDE_CODE_HOOK_KEYS, SELFTUNE_CONFIG_DIR, SELFTUNE_CONFIG_PATH } from "./constants.js";
28
29
  import type { SelftuneConfig } from "./types.js";
29
30
  import { hookKeyHasSelftuneEntry } from "./utils/hooks.js";
@@ -589,9 +590,12 @@ export async function cliMain(): Promise<void> {
589
590
  }),
590
591
  );
591
592
 
593
+ // Print telemetry disclosure
594
+ console.error(TELEMETRY_NOTICE);
595
+
592
596
  // Run doctor as post-check
593
597
  const { doctor } = await import("./observability.js");
594
- const doctorResult = doctor();
598
+ const doctorResult = await doctor();
595
599
  console.log(
596
600
  JSON.stringify({
597
601
  level: "info",
@@ -203,12 +203,73 @@ export function checkConfigHealth(): HealthCheck[] {
203
203
  return [check];
204
204
  }
205
205
 
206
- export function doctor(): DoctorResult {
206
+ /**
207
+ * Compare two semver strings. Returns:
208
+ * -1 if a < b, 0 if equal, 1 if a > b.
209
+ * Handles standard x.y.z versions; pre-release tags are not compared.
210
+ */
211
+ function compareSemver(a: string, b: string): -1 | 0 | 1 {
212
+ const pa = a.split(".").map(Number);
213
+ const pb = b.split(".").map(Number);
214
+ for (let i = 0; i < 3; i++) {
215
+ const va = pa[i] ?? 0;
216
+ const vb = pb[i] ?? 0;
217
+ if (va < vb) return -1;
218
+ if (va > vb) return 1;
219
+ }
220
+ return 0;
221
+ }
222
+
223
+ /** Check if the installed version is the latest on npm. Non-blocking, warns on stale. */
224
+ export async function checkVersionHealth(): Promise<HealthCheck[]> {
225
+ const check: HealthCheck = {
226
+ name: "version_up_to_date",
227
+ path: "package.json",
228
+ status: "pass",
229
+ message: "",
230
+ };
231
+
232
+ try {
233
+ const pkgPath = join(import.meta.dir, "../../package.json");
234
+ const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
235
+
236
+ const controller = new AbortController();
237
+ const timeout = setTimeout(() => controller.abort(), 3000);
238
+ try {
239
+ const res = await fetch("https://registry.npmjs.org/selftune/latest", {
240
+ signal: controller.signal,
241
+ });
242
+
243
+ if (res.ok) {
244
+ const data = (await res.json()) as { version: string };
245
+ const latestVersion = data.version;
246
+ const cmp = compareSemver(currentVersion, latestVersion);
247
+ if (cmp >= 0) {
248
+ check.message = `v${currentVersion} (latest)`;
249
+ } else {
250
+ check.status = "warn";
251
+ check.message = `v${currentVersion} installed, v${latestVersion} available. Run: npx skills add selftune-dev/selftune`;
252
+ }
253
+ } else {
254
+ check.message = `v${currentVersion} (unable to check npm registry)`;
255
+ }
256
+ } finally {
257
+ clearTimeout(timeout);
258
+ }
259
+ } catch {
260
+ check.message = "Unable to check latest version (network unavailable)";
261
+ }
262
+
263
+ return [check];
264
+ }
265
+
266
+ export async function doctor(): Promise<DoctorResult> {
207
267
  const allChecks = [
208
268
  ...checkConfigHealth(),
209
269
  ...checkLogHealth(),
210
270
  ...checkHookInstallation(),
211
271
  ...checkEvolutionHealth(),
272
+ ...(await checkVersionHealth()),
212
273
  ];
213
274
  const passed = allChecks.filter((c) => c.status === "pass").length;
214
275
  const failed = allChecks.filter((c) => c.status === "fail").length;
@@ -224,7 +285,7 @@ export function doctor(): DoctorResult {
224
285
  }
225
286
 
226
287
  if (import.meta.main) {
227
- const result = doctor();
288
+ const result = await doctor();
228
289
  console.log(JSON.stringify(result, null, 2));
229
290
  process.exit(result.healthy ? 0 : 1);
230
291
  }
@@ -652,7 +652,7 @@ export async function orchestrate(
652
652
  const skillRecords = _readSkillRecords();
653
653
  const queryRecords = _readQueryRecords();
654
654
  const auditEntries = _readAuditEntries();
655
- const doctorResult = _doctor();
655
+ const doctorResult = await _doctor();
656
656
 
657
657
  const statusResult = _computeStatus(
658
658
  telemetry,
@@ -115,7 +115,7 @@ export async function quickstart(): Promise<void> {
115
115
 
116
116
  try {
117
117
  const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
118
- const doctorResult = doctor();
118
+ const doctorResult = await doctor();
119
119
 
120
120
  const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
121
121
  const output = formatStatus(result);
@@ -324,13 +324,13 @@ function colorize(text: string, hex: string): string {
324
324
  // cliMain — reads logs, runs doctor, prints output
325
325
  // ---------------------------------------------------------------------------
326
326
 
327
- export function cliMain(): void {
327
+ export async function cliMain(): Promise<void> {
328
328
  try {
329
329
  const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
330
330
  const skillRecords = readEffectiveSkillUsageRecords();
331
331
  const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
332
332
  const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
333
- const doctorResult = doctor();
333
+ const doctorResult = await doctor();
334
334
 
335
335
  const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
336
336
  const output = formatStatus(result);
@@ -13,6 +13,7 @@ export interface SelftuneConfig {
13
13
  agent_cli: string | null;
14
14
  hooks_installed: boolean;
15
15
  initialized_at: string;
16
+ analytics_disabled?: boolean;
16
17
  }
17
18
 
18
19
  // ---------------------------------------------------------------------------
@@ -67,7 +67,8 @@ export function stripMarkdownFences(raw: string): string {
67
67
  const newlineIdx = inner.indexOf("\n");
68
68
  inner = newlineIdx >= 0 ? inner.slice(newlineIdx + 1) : inner.slice(fence.length);
69
69
  // Find matching closing fence (same length of backticks on its own line)
70
- const closingPattern = new RegExp(`^${fence.replace(/`/g, "\\`")}\\s*$`, "m");
70
+ const escapedFence = fence.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
71
+ const closingPattern = new RegExp(`^${escapedFence}\\s*$`, "m");
71
72
  const closingMatch = inner.match(closingPattern);
72
73
  if (closingMatch && closingMatch.index != null) {
73
74
  inner = inner.slice(0, closingMatch.index);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "selftune",
3
- "version": "0.2.2",
3
+ "version": "0.2.5",
4
4
  "description": "Self-improving skills CLI for AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "cli/selftune/",
43
43
  "apps/local-dashboard/dist/",
44
44
  "packages/telemetry-contract/",
45
+ "packages/ui/",
45
46
  "templates/",
46
47
  ".claude/agents/",
47
48
  "skill/",
@@ -63,13 +64,14 @@
63
64
  "start": "bun run cli/selftune/index.ts --help"
64
65
  },
65
66
  "workspaces": [
66
- "packages/*"
67
+ "packages/*",
68
+ "apps/*"
67
69
  ],
68
70
  "dependencies": {
69
71
  "@selftune/telemetry-contract": "workspace:*"
70
72
  },
71
73
  "devDependencies": {
72
- "@biomejs/biome": "2.4.7",
74
+ "@biomejs/biome": "^2.4.7",
73
75
  "@types/bun": "^1.1.0"
74
76
  }
75
77
  }