selftune 0.2.6 → 0.2.8

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-Bk9vSHHd.js +15 -0
  3. package/apps/local-dashboard/dist/assets/index-CRtLkBTi.css +1 -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 +30 -9
  9. package/cli/selftune/agent-guidance.ts +96 -0
  10. package/cli/selftune/alpha-identity.ts +157 -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 +242 -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 +32 -5
  24. package/cli/selftune/dashboard-contract.ts +32 -1
  25. package/cli/selftune/dashboard-server.ts +256 -692
  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 +27 -9
  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 +13 -4
  40. package/cli/selftune/grading/grade-session.ts +16 -6
  41. package/cli/selftune/hooks/evolution-guard.ts +26 -9
  42. package/cli/selftune/hooks/prompt-log.ts +23 -9
  43. package/cli/selftune/hooks/session-stop.ts +78 -15
  44. package/cli/selftune/hooks/skill-eval.ts +189 -10
  45. package/cli/selftune/index.ts +274 -2
  46. package/cli/selftune/ingestors/claude-replay.ts +48 -21
  47. package/cli/selftune/init.ts +249 -47
  48. package/cli/selftune/last.ts +7 -7
  49. package/cli/selftune/localdb/db.ts +90 -10
  50. package/cli/selftune/localdb/direct-write.ts +531 -0
  51. package/cli/selftune/localdb/materialize.ts +296 -42
  52. package/cli/selftune/localdb/queries.ts +325 -32
  53. package/cli/selftune/localdb/schema.ts +109 -0
  54. package/cli/selftune/monitoring/watch.ts +26 -8
  55. package/cli/selftune/normalization.ts +85 -15
  56. package/cli/selftune/observability.ts +248 -2
  57. package/cli/selftune/orchestrate.ts +165 -20
  58. package/cli/selftune/quickstart.ts +34 -10
  59. package/cli/selftune/repair/skill-usage.ts +12 -2
  60. package/cli/selftune/routes/actions.ts +77 -0
  61. package/cli/selftune/routes/badge.ts +66 -0
  62. package/cli/selftune/routes/doctor.ts +12 -0
  63. package/cli/selftune/routes/index.ts +14 -0
  64. package/cli/selftune/routes/orchestrate-runs.ts +13 -0
  65. package/cli/selftune/routes/overview.ts +14 -0
  66. package/cli/selftune/routes/report.ts +293 -0
  67. package/cli/selftune/routes/skill-report.ts +230 -0
  68. package/cli/selftune/status.ts +203 -7
  69. package/cli/selftune/sync.ts +13 -1
  70. package/cli/selftune/types.ts +50 -0
  71. package/cli/selftune/utils/jsonl.ts +58 -1
  72. package/cli/selftune/utils/selftune-meta.ts +38 -0
  73. package/cli/selftune/utils/skill-log.ts +30 -4
  74. package/cli/selftune/utils/transcript.ts +15 -0
  75. package/cli/selftune/workflows/workflows.ts +7 -6
  76. package/package.json +10 -6
  77. package/packages/telemetry-contract/fixtures/complete-push.ts +184 -0
  78. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +58 -0
  79. package/packages/telemetry-contract/fixtures/golden.json +1 -0
  80. package/packages/telemetry-contract/fixtures/index.ts +4 -0
  81. package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +40 -0
  82. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +79 -0
  83. package/packages/telemetry-contract/package.json +6 -1
  84. package/packages/telemetry-contract/src/index.ts +1 -0
  85. package/packages/telemetry-contract/src/schemas.ts +215 -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 +44 -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 +172 -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 +9 -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,7 +2,7 @@
2
2
  * Canonical telemetry normalization helpers.
3
3
  *
4
4
  * This module provides shared functions that all platform adapters call
5
- * to produce canonical records alongside their raw JSONL output.
5
+ * to produce canonical records written to SQLite via writeCanonicalToDb().
6
6
  *
7
7
  * Contract rules (from telemetry-field-map.md):
8
8
  * 1. Normalization is additive — raw capture is preserved separately.
@@ -25,6 +25,7 @@ import {
25
25
  } from "node:fs";
26
26
  import { basename, dirname } from "node:path";
27
27
  import { CANONICAL_LOG, canonicalSessionStatePath } from "./constants.js";
28
+ import { writeCanonicalBatchToDb, writeCanonicalToDb } from "./localdb/direct-write.js";
28
29
  import {
29
30
  CANONICAL_SCHEMA_VERSION,
30
31
  type CanonicalCaptureMode,
@@ -81,9 +82,46 @@ function defaultPromptSessionState(sessionId: string): CanonicalPromptSessionSta
81
82
 
82
83
  function derivePromptSessionStateFromCanonicalLog(
83
84
  sessionId: string,
84
- canonicalLogPath: string = CANONICAL_LOG,
85
+ _canonicalLogPath: string = CANONICAL_LOG,
85
86
  ): CanonicalPromptSessionState {
86
87
  const recovered = defaultPromptSessionState(sessionId);
88
+
89
+ // Try SQLite first — canonical records now go to the local DB.
90
+ // Uses dynamic require + try/catch so this remains fail-safe during
91
+ // hook execution when the DB module may not be loadable.
92
+ try {
93
+ const { getDb } = require("./localdb/db.js") as {
94
+ getDb: () => import("bun:sqlite").Database;
95
+ };
96
+ const db = getDb();
97
+ const rows = db
98
+ .query(
99
+ "SELECT prompt_id, prompt_index, is_actionable FROM prompts WHERE session_id = ? ORDER BY prompt_index DESC LIMIT 1",
100
+ )
101
+ .all(sessionId) as Array<{
102
+ prompt_id: string;
103
+ prompt_index: number;
104
+ is_actionable: number;
105
+ }>;
106
+ if (rows.length > 0) {
107
+ const row = rows[0];
108
+ recovered.next_prompt_index = row.prompt_index + 1;
109
+ recovered.last_prompt_id = row.prompt_id;
110
+ // Get last actionable
111
+ const actionable = db
112
+ .query(
113
+ "SELECT prompt_id, prompt_index FROM prompts WHERE session_id = ? AND is_actionable = 1 ORDER BY prompt_index DESC LIMIT 1",
114
+ )
115
+ .get(sessionId) as { prompt_id: string; prompt_index: number } | null;
116
+ if (actionable) recovered.last_actionable_prompt_id = actionable.prompt_id;
117
+ return recovered;
118
+ }
119
+ } catch {
120
+ // DB unavailable — fall through to JSONL recovery below.
121
+ }
122
+
123
+ // Fallback: scan canonical JSONL log (legacy path or DB unavailable).
124
+ const canonicalLogPath = _canonicalLogPath;
87
125
  let maxPromptIndex = -1;
88
126
  let maxActionablePromptIndex = -1;
89
127
 
@@ -346,22 +384,32 @@ export function getLatestPromptIdentity(
346
384
  };
347
385
  }
348
386
 
349
- export function appendCanonicalRecord(
350
- record: CanonicalRecord,
351
- logPath: string = CANONICAL_LOG,
352
- ): void {
353
- const dir = dirname(logPath);
354
- if (!existsSync(dir)) {
355
- mkdirSync(dir, { recursive: true });
387
+ export function appendCanonicalRecord(record: CanonicalRecord, logPath?: string): void {
388
+ writeCanonicalToDb(record);
389
+ // JSONL append — best-effort backup for prompt state recovery
390
+ try {
391
+ const path = logPath ?? CANONICAL_LOG;
392
+ const dir = dirname(path);
393
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
394
+ appendFileSync(path, `${JSON.stringify(record)}\n`, "utf-8");
395
+ } catch {
396
+ /* best-effort only */
356
397
  }
357
- appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf-8");
358
398
  }
359
399
 
360
- export function appendCanonicalRecords(
361
- records: CanonicalRecord[],
362
- logPath: string = CANONICAL_LOG,
363
- ): void {
364
- for (const record of records) appendCanonicalRecord(record, logPath);
400
+ export function appendCanonicalRecords(records: CanonicalRecord[], logPath?: string): void {
401
+ writeCanonicalBatchToDb(records);
402
+ // JSONL append — best-effort backup for prompt state recovery
403
+ try {
404
+ const path = logPath ?? CANONICAL_LOG;
405
+ const dir = dirname(path);
406
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
407
+ for (const record of records) {
408
+ appendFileSync(path, `${JSON.stringify(record)}\n`, "utf-8");
409
+ }
410
+ } catch {
411
+ /* best-effort only */
412
+ }
365
413
  }
366
414
 
367
415
  // ---------------------------------------------------------------------------
@@ -439,14 +487,34 @@ export interface InvocationClassification {
439
487
 
440
488
  /**
441
489
  * Classify how a skill was invoked.
490
+ *
491
+ * When `hook_invocation_type` is provided (from the skill-eval hook's
492
+ * classifyInvocationType), it takes precedence over the legacy heuristics:
493
+ * - "explicit" → user typed /skill (slash command) → explicit, confidence 1.0
494
+ * - "implicit" → user named the skill, Claude invoked it → implicit, confidence 0.85
495
+ * - "inferred" → Claude chose skill autonomously → inferred, confidence 0.6
496
+ * - "contextual" → SKILL.md was read (Read tool, not Skill tool) → inferred, confidence 0.5
442
497
  */
443
498
  export function deriveInvocationMode(opts: {
444
499
  has_skill_tool_call?: boolean;
445
500
  has_skill_md_read?: boolean;
446
501
  is_text_mention_only?: boolean;
447
502
  is_repaired?: boolean;
503
+ hook_invocation_type?: "explicit" | "implicit" | "inferred" | "contextual";
448
504
  }): InvocationClassification {
449
505
  if (opts.is_repaired) return { invocation_mode: "repaired", confidence: 0.9 };
506
+
507
+ // Prefer hook-level classification when available
508
+ if (opts.hook_invocation_type === "explicit")
509
+ return { invocation_mode: "explicit", confidence: 1.0 };
510
+ if (opts.hook_invocation_type === "implicit")
511
+ return { invocation_mode: "implicit", confidence: 0.85 };
512
+ if (opts.hook_invocation_type === "inferred")
513
+ return { invocation_mode: "inferred", confidence: 0.6 };
514
+ if (opts.hook_invocation_type === "contextual")
515
+ return { invocation_mode: "inferred", confidence: 0.5 };
516
+
517
+ // Legacy fallback for callers that don't pass hook_invocation_type
450
518
  if (opts.has_skill_tool_call) return { invocation_mode: "explicit", confidence: 1.0 };
451
519
  if (opts.has_skill_md_read) return { invocation_mode: "implicit", confidence: 0.7 };
452
520
  if (opts.is_text_mention_only) return { invocation_mode: "inferred", confidence: 0.4 };
@@ -613,6 +681,7 @@ export interface BuildSkillInvocationInput extends CanonicalBaseInput {
613
681
  confidence: number;
614
682
  tool_name?: string;
615
683
  tool_call_id?: string;
684
+ agent_type?: string;
616
685
  }
617
686
 
618
687
  export function buildCanonicalSkillInvocation(
@@ -636,6 +705,7 @@ export function buildCanonicalSkillInvocation(
636
705
  if (input.skill_version_hash !== undefined) record.skill_version_hash = input.skill_version_hash;
637
706
  if (input.tool_name !== undefined) record.tool_name = input.tool_name;
638
707
  if (input.tool_call_id !== undefined) record.tool_call_id = input.tool_call_id;
708
+ if (input.agent_type !== undefined) record.agent_type = input.agent_type;
639
709
 
640
710
  return record;
641
711
  }
@@ -11,8 +11,18 @@
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
+ import { getAlphaGuidance } from "./agent-guidance.js";
15
+ import { getAlphaLinkState, readAlphaIdentity } from "./alpha-identity.js";
14
16
  import { LOG_DIR, REQUIRED_FIELDS, SELFTUNE_CONFIG_PATH } from "./constants.js";
15
- import type { DoctorResult, HealthCheck, HealthStatus, SelftuneConfig } from "./types.js";
17
+ import { DB_PATH, getDb } from "./localdb/db.js";
18
+ import type {
19
+ AlphaIdentity,
20
+ AlphaLinkState,
21
+ DoctorResult,
22
+ HealthCheck,
23
+ HealthStatus,
24
+ SelftuneConfig,
25
+ } from "./types.js";
16
26
  import { missingClaudeCodeHookKeys } from "./utils/hooks.js";
17
27
 
18
28
  const VALID_AGENT_TYPES = new Set(["claude_code", "codex", "opencode", "openclaw", "unknown"]);
@@ -116,6 +126,13 @@ export function checkHookInstallation(): HealthCheck[] {
116
126
  if (!existsSync(settingsPath)) {
117
127
  settingsCheck.status = "warn";
118
128
  settingsCheck.message = "Claude Code settings.json not found";
129
+ settingsCheck.guidance = {
130
+ code: "hook_settings_missing",
131
+ message: "Claude Code settings.json is missing. Re-run init to install the selftune hooks.",
132
+ next_command: "selftune init --force",
133
+ suggested_commands: ["selftune doctor"],
134
+ blocking: true,
135
+ };
119
136
  } else {
120
137
  try {
121
138
  const raw = readFileSync(settingsPath, "utf-8");
@@ -124,11 +141,25 @@ export function checkHookInstallation(): HealthCheck[] {
124
141
  if (!hooks || typeof hooks !== "object") {
125
142
  settingsCheck.status = "warn";
126
143
  settingsCheck.message = "No hooks section in settings.json";
144
+ settingsCheck.guidance = {
145
+ code: "hook_settings_missing",
146
+ message: "The Claude Code hooks are not configured yet.",
147
+ next_command: "selftune init --force",
148
+ suggested_commands: ["selftune doctor"],
149
+ blocking: true,
150
+ };
127
151
  } else {
128
152
  const missing = missingClaudeCodeHookKeys(hooks as Record<string, unknown>);
129
153
  if (missing.length > 0) {
130
154
  settingsCheck.status = "warn";
131
155
  settingsCheck.message = `Selftune hooks not configured for: ${missing.join(", ")}`;
156
+ settingsCheck.guidance = {
157
+ code: "hook_settings_incomplete",
158
+ message: "Some Claude Code hooks are missing.",
159
+ next_command: "selftune init --force",
160
+ suggested_commands: ["selftune doctor"],
161
+ blocking: true,
162
+ };
132
163
  } else {
133
164
  settingsCheck.status = "pass";
134
165
  settingsCheck.message = "All selftune hooks configured in settings.json";
@@ -165,6 +196,18 @@ export function checkEvolutionHealth(): HealthCheck[] {
165
196
  return [check];
166
197
  }
167
198
 
199
+ export function checkDashboardIntegrityHealth(): HealthCheck[] {
200
+ const check: HealthCheck = {
201
+ name: "dashboard_freshness_mode",
202
+ path: DB_PATH,
203
+ status: "warn",
204
+ message:
205
+ "Dashboard reads SQLite, but live refresh still relies on JSONL watcher invalidation instead of SQLite WAL. Expect freshness gaps for SQLite-only writes and export before destructive recovery.",
206
+ };
207
+
208
+ return [check];
209
+ }
210
+
168
211
  export function checkConfigHealth(): HealthCheck[] {
169
212
  const check: HealthCheck = {
170
213
  name: "config",
@@ -176,6 +219,13 @@ export function checkConfigHealth(): HealthCheck[] {
176
219
  if (!existsSync(SELFTUNE_CONFIG_PATH)) {
177
220
  check.status = "warn";
178
221
  check.message = "Config not found. Run 'selftune init' to bootstrap.";
222
+ check.guidance = {
223
+ code: "config_missing",
224
+ message: "selftune is not initialized yet.",
225
+ next_command: "selftune init",
226
+ suggested_commands: ["selftune doctor"],
227
+ blocking: true,
228
+ };
179
229
  } else {
180
230
  try {
181
231
  const raw = readFileSync(SELFTUNE_CONFIG_PATH, "utf-8");
@@ -190,6 +240,13 @@ export function checkConfigHealth(): HealthCheck[] {
190
240
  if (errors.length > 0) {
191
241
  check.status = "fail";
192
242
  check.message = errors.join("; ");
243
+ check.guidance = {
244
+ code: "config_invalid",
245
+ message: "The selftune config is invalid and needs to be regenerated.",
246
+ next_command: "selftune init --force",
247
+ suggested_commands: ["selftune doctor"],
248
+ blocking: true,
249
+ };
193
250
  } else {
194
251
  check.status = "pass";
195
252
  check.message = `agent_type=${config.agent_type}, llm_mode=${config.llm_mode}`;
@@ -197,6 +254,13 @@ export function checkConfigHealth(): HealthCheck[] {
197
254
  } catch {
198
255
  check.status = "fail";
199
256
  check.message = "Config file exists but is not valid JSON";
257
+ check.guidance = {
258
+ code: "config_invalid_json",
259
+ message: "The selftune config file is corrupt JSON.",
260
+ next_command: "selftune init --force",
261
+ suggested_commands: ["selftune doctor"],
262
+ blocking: true,
263
+ };
200
264
  }
201
265
  }
202
266
 
@@ -249,6 +313,13 @@ export async function checkVersionHealth(): Promise<HealthCheck[]> {
249
313
  } else {
250
314
  check.status = "warn";
251
315
  check.message = `v${currentVersion} installed, v${latestVersion} available. Run: npx skills add selftune-dev/selftune`;
316
+ check.guidance = {
317
+ code: "version_update_available",
318
+ message: "A newer selftune release is available.",
319
+ next_command: "npx skills add selftune-dev/selftune",
320
+ suggested_commands: ["selftune doctor"],
321
+ blocking: false,
322
+ };
252
323
  }
253
324
  } else {
254
325
  check.message = `v${currentVersion} (unable to check npm registry)`;
@@ -263,24 +334,199 @@ export async function checkVersionHealth(): Promise<HealthCheck[]> {
263
334
  return [check];
264
335
  }
265
336
 
337
+ // ---------------------------------------------------------------------------
338
+ // Alpha upload queue health checks
339
+ // ---------------------------------------------------------------------------
340
+
341
+ const ALPHA_STUCK_THRESHOLD_SECONDS = 3600; // 1 hour
342
+ const ALPHA_FAILURE_THRESHOLD = 50;
343
+
344
+ export interface AlphaQueueCheckOptions {
345
+ stuckThresholdSeconds?: number;
346
+ failureThreshold?: number;
347
+ }
348
+
349
+ /**
350
+ * Check alpha upload queue health.
351
+ * Returns empty array when not enrolled (checks are skipped).
352
+ */
353
+ export async function checkAlphaQueueHealth(
354
+ db: import("bun:sqlite").Database,
355
+ enrolled: boolean,
356
+ opts?: AlphaQueueCheckOptions,
357
+ ): Promise<HealthCheck[]> {
358
+ if (!enrolled) return [];
359
+
360
+ const { getQueueStats } = await import("./alpha-upload/queue.js");
361
+ const { getOldestPendingAge } = await import("./localdb/queries.js");
362
+
363
+ const checks: HealthCheck[] = [];
364
+ const stuckThreshold = opts?.stuckThresholdSeconds ?? ALPHA_STUCK_THRESHOLD_SECONDS;
365
+ const failureThreshold = opts?.failureThreshold ?? ALPHA_FAILURE_THRESHOLD;
366
+
367
+ // Check for stuck pending items
368
+ const stuckCheck: HealthCheck = {
369
+ name: "alpha_queue_stuck",
370
+ path: "upload_queue",
371
+ status: "pass",
372
+ message: "",
373
+ };
374
+
375
+ const oldestAge = getOldestPendingAge(db);
376
+ if (oldestAge !== null && oldestAge > stuckThreshold) {
377
+ stuckCheck.status = "warn";
378
+ const hours = Math.floor(oldestAge / 3600);
379
+ const minutes = Math.floor((oldestAge % 3600) / 60);
380
+ stuckCheck.message = `Oldest pending upload is ${hours}h ${minutes}m old (threshold: ${Math.floor(stuckThreshold / 3600)}h)`;
381
+ stuckCheck.guidance = {
382
+ code: "alpha_queue_stuck",
383
+ message: "The alpha upload queue has pending items that are not draining.",
384
+ next_command: "selftune alpha upload",
385
+ suggested_commands: ["selftune doctor", "selftune status"],
386
+ blocking: false,
387
+ };
388
+ } else {
389
+ stuckCheck.message =
390
+ oldestAge !== null
391
+ ? `Oldest pending item: ${Math.floor(oldestAge / 60)}m old`
392
+ : "No pending items";
393
+ }
394
+ checks.push(stuckCheck);
395
+
396
+ // Check for excessive failures
397
+ const failCheck: HealthCheck = {
398
+ name: "alpha_queue_failures",
399
+ path: "upload_queue",
400
+ status: "pass",
401
+ message: "",
402
+ };
403
+
404
+ const stats = getQueueStats(db);
405
+ if (stats.failed > failureThreshold) {
406
+ failCheck.status = "warn";
407
+ failCheck.message = `${stats.failed} failed uploads (threshold: ${failureThreshold})`;
408
+ failCheck.guidance = {
409
+ code: "alpha_queue_failures",
410
+ message: "The alpha upload queue has accumulated too many failures.",
411
+ next_command: "selftune alpha upload",
412
+ suggested_commands: ["selftune doctor", "selftune status"],
413
+ blocking: false,
414
+ };
415
+ } else {
416
+ failCheck.message = `${stats.failed} failed uploads`;
417
+ }
418
+ checks.push(failCheck);
419
+
420
+ return checks;
421
+ }
422
+
423
+ export function checkSkillVersionSync(): HealthCheck[] {
424
+ const check: HealthCheck = {
425
+ name: "skill_version_sync",
426
+ path: "skill/SKILL.md",
427
+ status: "pass",
428
+ message: "",
429
+ };
430
+
431
+ try {
432
+ const pkgPath = join(import.meta.dir, "../../package.json");
433
+ const pkgVersion: string = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
434
+
435
+ const skillPath = join(import.meta.dir, "../../skill/SKILL.md");
436
+ if (!existsSync(skillPath)) {
437
+ check.status = "warn";
438
+ check.message = "skill/SKILL.md not found (may be running from installed package)";
439
+ return [check];
440
+ }
441
+
442
+ const skillContent = readFileSync(skillPath, "utf-8");
443
+ const versionMatch = skillContent.match(/^\s*version:\s*(.+)$/m);
444
+ if (!versionMatch) {
445
+ check.status = "warn";
446
+ check.message = "No version field found in SKILL.md frontmatter";
447
+ return [check];
448
+ }
449
+
450
+ const skillVersion = versionMatch[1].trim();
451
+ if (skillVersion === pkgVersion) {
452
+ check.message = `v${pkgVersion} (in sync)`;
453
+ } else {
454
+ check.status = "warn";
455
+ check.message = `SKILL.md has v${skillVersion} but package.json has v${pkgVersion}. Run: bun run sync-version`;
456
+ check.guidance = {
457
+ code: "skill_version_out_of_sync",
458
+ message: "The packaged skill version does not match package.json.",
459
+ next_command: "bun run sync-version",
460
+ suggested_commands: ["selftune doctor"],
461
+ blocking: false,
462
+ };
463
+ }
464
+ } catch {
465
+ check.status = "warn";
466
+ check.message = "Unable to compare versions";
467
+ }
468
+
469
+ return [check];
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // Cloud link health checks
474
+ // ---------------------------------------------------------------------------
475
+
476
+ /**
477
+ * Check cloud link health for alpha users.
478
+ * Returns [] for non-alpha users (identity is null).
479
+ */
480
+ const CLOUD_LINK_CHECKS: Record<AlphaLinkState, { status: HealthStatus; message: string }> = {
481
+ not_linked: { status: "warn", message: "Not linked to cloud account (cloud_user_id missing)" },
482
+ linked_not_enrolled: { status: "warn", message: "Linked but not enrolled" },
483
+ enrolled_no_credential: {
484
+ status: "warn",
485
+ message: "Enrolled but api_key missing — uploads will fail",
486
+ },
487
+ ready: { status: "pass", message: "Cloud link ready" },
488
+ };
489
+
490
+ export function checkCloudLinkHealth(identity: AlphaIdentity | null): HealthCheck[] {
491
+ if (!identity) return [];
492
+ const state = getAlphaLinkState(identity);
493
+ const { status, message } = CLOUD_LINK_CHECKS[state];
494
+ return [
495
+ {
496
+ name: "cloud_link",
497
+ path: SELFTUNE_CONFIG_PATH,
498
+ status,
499
+ message,
500
+ guidance: getAlphaGuidance(identity),
501
+ },
502
+ ];
503
+ }
504
+
266
505
  export async function doctor(): Promise<DoctorResult> {
506
+ const alphaIdentity = readAlphaIdentity(SELFTUNE_CONFIG_PATH);
507
+ const db = getDb();
267
508
  const allChecks = [
268
509
  ...checkConfigHealth(),
269
510
  ...checkLogHealth(),
270
511
  ...checkHookInstallation(),
271
512
  ...checkEvolutionHealth(),
513
+ ...checkDashboardIntegrityHealth(),
514
+ ...checkSkillVersionSync(),
272
515
  ...(await checkVersionHealth()),
516
+ ...checkCloudLinkHealth(alphaIdentity),
517
+ ...(await checkAlphaQueueHealth(db, alphaIdentity?.enrolled === true)),
273
518
  ];
274
519
  const passed = allChecks.filter((c) => c.status === "pass").length;
275
520
  const failed = allChecks.filter((c) => c.status === "fail").length;
276
521
  const warned = allChecks.filter((c) => c.status === "warn").length;
522
+ const hasBlockingGuidance = allChecks.some((c) => c.guidance?.blocking === true);
277
523
 
278
524
  return {
279
525
  command: "doctor",
280
526
  timestamp: new Date().toISOString(),
281
527
  checks: allChecks,
282
528
  summary: { pass: passed, fail: failed, warn: warned, total: allChecks.length },
283
- healthy: failed === 0,
529
+ healthy: failed === 0 && !hasBlockingGuidance,
284
530
  };
285
531
  }
286
532