selftune 0.2.22 → 0.2.23

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 (94) hide show
  1. package/README.md +4 -2
  2. package/apps/local-dashboard/dist/assets/index-CwOtTrUS.css +1 -0
  3. package/apps/local-dashboard/dist/assets/index-f1HQpbeH.js +59 -0
  4. package/apps/local-dashboard/dist/assets/vendor-ui-jVSaIZey.js +12 -0
  5. package/apps/local-dashboard/dist/index.html +3 -3
  6. package/cli/selftune/adapters/pi/hook.ts +273 -0
  7. package/cli/selftune/adapters/pi/install.ts +207 -0
  8. package/cli/selftune/constants.ts +10 -1
  9. package/cli/selftune/dashboard-contract.ts +14 -0
  10. package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
  11. package/cli/selftune/evolution/engines/replay-engine.ts +158 -0
  12. package/cli/selftune/evolution/evidence.ts +2 -6
  13. package/cli/selftune/evolution/evolve-body.ts +73 -20
  14. package/cli/selftune/evolution/validate-body.ts +78 -42
  15. package/cli/selftune/evolution/validate-routing.ts +45 -104
  16. package/cli/selftune/hooks/skill-eval.ts +2 -1
  17. package/cli/selftune/hooks-shared/types.ts +1 -0
  18. package/cli/selftune/index.ts +23 -5
  19. package/cli/selftune/ingestors/pi-ingest.ts +726 -0
  20. package/cli/selftune/init.ts +11 -1
  21. package/cli/selftune/localdb/direct-write.ts +85 -0
  22. package/cli/selftune/localdb/materialize.ts +6 -7
  23. package/cli/selftune/localdb/queries.ts +126 -0
  24. package/cli/selftune/localdb/schema.ts +38 -0
  25. package/cli/selftune/observability.ts +8 -1
  26. package/cli/selftune/orchestrate.ts +43 -0
  27. package/cli/selftune/registry/client.ts +74 -0
  28. package/cli/selftune/registry/history.ts +54 -0
  29. package/cli/selftune/registry/index.ts +90 -0
  30. package/cli/selftune/registry/install.ts +141 -0
  31. package/cli/selftune/registry/list.ts +44 -0
  32. package/cli/selftune/registry/push.ts +171 -0
  33. package/cli/selftune/registry/rollback.ts +49 -0
  34. package/cli/selftune/registry/status.ts +62 -0
  35. package/cli/selftune/registry/sync.ts +125 -0
  36. package/cli/selftune/repair/skill-usage.ts +4 -1
  37. package/cli/selftune/status.ts +31 -0
  38. package/cli/selftune/sync.ts +127 -23
  39. package/cli/selftune/types.ts +2 -1
  40. package/cli/selftune/utils/jsonl.ts +1 -30
  41. package/cli/selftune/utils/skill-discovery.ts +22 -0
  42. package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
  43. package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
  44. package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
  45. package/node_modules/@selftune/telemetry-contract/package.json +1 -1
  46. package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
  47. package/node_modules/@selftune/telemetry-contract/src/schemas.ts +22 -4
  48. package/node_modules/@selftune/telemetry-contract/src/types.ts +1 -12
  49. package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
  50. package/package.json +1 -1
  51. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
  52. package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
  53. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
  54. package/packages/telemetry-contract/package.json +1 -1
  55. package/packages/telemetry-contract/src/index.ts +1 -0
  56. package/packages/telemetry-contract/src/schemas.ts +22 -4
  57. package/packages/telemetry-contract/src/types.ts +1 -12
  58. package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
  59. package/packages/ui/AGENTS.md +16 -0
  60. package/packages/ui/README.md +1 -1
  61. package/packages/ui/package.json +1 -1
  62. package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
  63. package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
  64. package/packages/ui/src/components/EvidenceViewer.tsx +153 -443
  65. package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
  66. package/packages/ui/src/components/InfoTip.tsx +1 -2
  67. package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
  68. package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
  69. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
  70. package/packages/ui/src/components/OverviewPanels.tsx +652 -0
  71. package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
  72. package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
  73. package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
  74. package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
  75. package/packages/ui/src/components/index.ts +56 -1
  76. package/packages/ui/src/components/section-cards.tsx +18 -35
  77. package/packages/ui/src/components/skill-health-grid.tsx +47 -37
  78. package/packages/ui/src/lib/constants.tsx +0 -1
  79. package/packages/ui/src/primitives/card.tsx +1 -1
  80. package/packages/ui/src/primitives/checkbox.tsx +1 -1
  81. package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
  82. package/packages/ui/src/primitives/select.tsx +2 -2
  83. package/packages/ui/src/types.ts +172 -4
  84. package/skill/SKILL.md +18 -4
  85. package/skill/Workflows/Ingest.md +60 -2
  86. package/skill/Workflows/Initialize.md +8 -5
  87. package/skill/Workflows/PlatformHooks.md +19 -3
  88. package/skill/Workflows/Registry.md +99 -0
  89. package/skill/Workflows/Sync.md +3 -1
  90. package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
  91. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
  92. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
  93. package/cli/selftune/utils/html.ts +0 -27
  94. package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
@@ -75,13 +75,16 @@ class InitCliError extends Error {
75
75
  * 1. Claude Code — ~/.claude/ directory exists AND (`which claude` OR env signals)
76
76
  * 2. Codex — $CODEX_HOME set OR `which codex`
77
77
  * 3. OpenCode — ~/.local/share/opencode/opencode.db exists OR `which opencode`
78
- * 4. "unknown" fallback
78
+ * 4. OpenClaw — ~/.openclaw/agents/ exists OR `which openclaw`
79
+ * 5. Pi — ~/.pi/agent/ exists OR `which pi`
80
+ * 6. "unknown" fallback
79
81
  */
80
82
  const VALID_AGENT_TYPES: SelftuneConfig["agent_type"][] = [
81
83
  "claude_code",
82
84
  "codex",
83
85
  "opencode",
84
86
  "openclaw",
87
+ "pi",
85
88
  "unknown",
86
89
  ];
87
90
 
@@ -90,6 +93,7 @@ const AGENT_TYPE_CLI_MAP: Record<string, string> = {
90
93
  codex: "codex",
91
94
  opencode: "opencode",
92
95
  openclaw: "openclaw",
96
+ pi: "pi",
93
97
  };
94
98
 
95
99
  function agentTypeToCli(agentType: string): string | null {
@@ -134,6 +138,12 @@ export function detectAgentType(
134
138
  return "openclaw";
135
139
  }
136
140
 
141
+ // Pi: .pi directory or binary
142
+ const piDir = join(home, ".pi", "agent");
143
+ if (existsSync(piDir) || Bun.which("pi")) {
144
+ return "pi";
145
+ }
146
+
137
147
  return "unknown";
138
148
  }
139
149
 
@@ -500,6 +500,91 @@ export function writeCommitTracking(record: {
500
500
  });
501
501
  }
502
502
 
503
+ // -- Cron run audit writer -----------------------------------------------------
504
+
505
+ export function writeCronRunToDb(
506
+ db: Database,
507
+ entry: {
508
+ jobName: string;
509
+ startedAt: string;
510
+ elapsedMs: number;
511
+ status: "success" | "error";
512
+ metrics?: Record<string, unknown>;
513
+ error?: string;
514
+ },
515
+ ): void {
516
+ try {
517
+ getStmt(
518
+ db,
519
+ "cron-run",
520
+ `
521
+ INSERT OR IGNORE INTO cron_runs
522
+ (job_name, started_at, elapsed_ms, status, metrics_json, error)
523
+ VALUES (?, ?, ?, ?, ?, ?)
524
+ `,
525
+ ).run(
526
+ entry.jobName,
527
+ entry.startedAt,
528
+ entry.elapsedMs,
529
+ entry.status,
530
+ entry.metrics ? JSON.stringify(entry.metrics) : null,
531
+ entry.error ?? null,
532
+ );
533
+ } catch {
534
+ /* fail-open: never throw from audit logging */
535
+ }
536
+ }
537
+
538
+ // -- Replay entry results writer -----------------------------------------------
539
+
540
+ export interface ReplayEntryResultInput {
541
+ proposal_id: string;
542
+ skill_name: string;
543
+ validation_mode: string;
544
+ phase: string;
545
+ query: string;
546
+ should_trigger: boolean;
547
+ triggered: boolean;
548
+ passed: boolean;
549
+ evidence?: string;
550
+ }
551
+
552
+ export function writeReplayEntryResultsToDb(results: ReplayEntryResultInput[]): boolean {
553
+ if (results.length === 0) return true;
554
+ return safeWrite("replay-entry-results", (db) => {
555
+ db.run("BEGIN TRANSACTION");
556
+ try {
557
+ const stmt = getStmt(
558
+ db,
559
+ "replay-entry-result",
560
+ `
561
+ INSERT INTO replay_entry_results
562
+ (proposal_id, skill_name, validation_mode, phase, query,
563
+ should_trigger, triggered, passed, evidence)
564
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
565
+ `,
566
+ );
567
+ for (const r of results) {
568
+ stmt.run(
569
+ r.proposal_id,
570
+ r.skill_name,
571
+ r.validation_mode,
572
+ r.phase,
573
+ r.query,
574
+ r.should_trigger ? 1 : 0,
575
+ r.triggered ? 1 : 0,
576
+ r.passed ? 1 : 0,
577
+ r.evidence ?? null,
578
+ );
579
+ }
580
+ db.run("COMMIT");
581
+ } catch (err) {
582
+ db.run("ROLLBACK");
583
+ throw err;
584
+ }
585
+ });
586
+ }
587
+
503
588
  // -- Internal insert helpers (used by cached statements) ----------------------
504
589
 
505
590
  function insertSession(db: Database, s: CanonicalSessionRecord): void {
@@ -1,18 +1,17 @@
1
1
  /**
2
- * Materializer: reads JSONL source-of-truth logs and inserts structured
2
+ * Materializer: reads legacy/exported JSONL files and inserts structured
3
3
  * records into the local SQLite database.
4
4
  *
5
+ * IMPORTANT: SQLite is the sole write target (Phase 3 complete). JSONL files
6
+ * on disk contain only pre-cutover history. This materializer is ONLY used for:
7
+ * 1. Recovery from selftune export JSONL snapshots
8
+ * 2. Backfill of pre-cutover JSONL data into a fresh SQLite DB
9
+ *
5
10
  * Supports two modes:
6
11
  * - Full rebuild: drops all data and re-inserts from scratch
7
12
  * - Incremental: only inserts records newer than last materialization
8
13
  */
9
14
 
10
- // NOTE: With dual-write active (Phase 1+), hooks insert directly into SQLite.
11
- // The materializer is only needed for:
12
- // 1. Initial startup (to catch pre-existing JSONL data from before dual-write)
13
- // 2. Manual recovery after exporting JSONL and recreating the DB file
14
- // 3. Backfill from batch ingestors that don't yet dual-write
15
-
16
15
  import type { Database } from "bun:sqlite";
17
16
 
18
17
  import {
@@ -2142,6 +2142,132 @@ export function getRecentDecisions(db: Database, limit = 20): AutonomousDecision
2142
2142
 
2143
2143
  // -- Helpers ------------------------------------------------------------------
2144
2144
 
2145
+ // -- Replay entry result queries -----------------------------------------------
2146
+
2147
+ export function queryReplayEntryResults(
2148
+ db: Database,
2149
+ proposalId: string,
2150
+ phase?: string,
2151
+ ): Array<{
2152
+ id: number;
2153
+ proposal_id: string;
2154
+ skill_name: string;
2155
+ validation_mode: string;
2156
+ phase: string;
2157
+ query: string;
2158
+ should_trigger: boolean;
2159
+ triggered: boolean;
2160
+ passed: boolean;
2161
+ evidence: string | null;
2162
+ }> {
2163
+ const sql = phase
2164
+ ? `SELECT id, proposal_id, skill_name, validation_mode, phase, query,
2165
+ should_trigger, triggered, passed, evidence
2166
+ FROM replay_entry_results
2167
+ WHERE proposal_id = ? AND phase = ?
2168
+ ORDER BY id`
2169
+ : `SELECT id, proposal_id, skill_name, validation_mode, phase, query,
2170
+ should_trigger, triggered, passed, evidence
2171
+ FROM replay_entry_results
2172
+ WHERE proposal_id = ?
2173
+ ORDER BY id`;
2174
+
2175
+ const rows = phase
2176
+ ? (db.query(sql).all(proposalId, phase) as Array<Record<string, unknown>>)
2177
+ : (db.query(sql).all(proposalId) as Array<Record<string, unknown>>);
2178
+
2179
+ return rows.map((r) => ({
2180
+ id: r.id as number,
2181
+ proposal_id: r.proposal_id as string,
2182
+ skill_name: r.skill_name as string,
2183
+ validation_mode: r.validation_mode as string,
2184
+ phase: r.phase as string,
2185
+ query: r.query as string,
2186
+ should_trigger: (r.should_trigger as number) === 1,
2187
+ triggered: (r.triggered as number) === 1,
2188
+ passed: (r.passed as number) === 1,
2189
+ evidence: r.evidence as string | null,
2190
+ }));
2191
+ }
2192
+
2193
+ /**
2194
+ * Find regressions: entries that passed in the "before" phase but failed in the "after" phase.
2195
+ */
2196
+ export function queryReplayRegressions(
2197
+ db: Database,
2198
+ proposalId: string,
2199
+ ): Array<{
2200
+ query: string;
2201
+ skill_name: string;
2202
+ before_passed: boolean;
2203
+ after_passed: boolean;
2204
+ }> {
2205
+ const rows = db
2206
+ .query(
2207
+ `SELECT b.query, b.skill_name,
2208
+ b.passed AS before_passed,
2209
+ a.passed AS after_passed
2210
+ FROM replay_entry_results b
2211
+ JOIN replay_entry_results a
2212
+ ON b.proposal_id = a.proposal_id
2213
+ AND b.query = a.query
2214
+ AND b.skill_name = a.skill_name
2215
+ WHERE b.proposal_id = ?
2216
+ AND b.phase = 'before'
2217
+ AND a.phase = 'after'
2218
+ AND b.passed = 1
2219
+ AND a.passed = 0
2220
+ ORDER BY b.query`,
2221
+ )
2222
+ .all(proposalId) as Array<Record<string, unknown>>;
2223
+
2224
+ return rows.map((r) => ({
2225
+ query: r.query as string,
2226
+ skill_name: r.skill_name as string,
2227
+ before_passed: (r.before_passed as number) === 1,
2228
+ after_passed: (r.after_passed as number) === 1,
2229
+ }));
2230
+ }
2231
+
2232
+ // -- JSON parse helpers -------------------------------------------------------
2233
+
2234
+ // -- Cron run queries ---------------------------------------------------------
2235
+
2236
+ export interface CronRun {
2237
+ id: number;
2238
+ job_name: string;
2239
+ started_at: string;
2240
+ elapsed_ms: number;
2241
+ status: string;
2242
+ metrics_json: string | null;
2243
+ error: string | null;
2244
+ }
2245
+
2246
+ export function getRecentCronRuns(db: Database, limit = 50): CronRun[] {
2247
+ return db
2248
+ .query(
2249
+ `SELECT id, job_name, started_at, elapsed_ms, status, metrics_json, error
2250
+ FROM cron_runs
2251
+ ORDER BY started_at DESC
2252
+ LIMIT ?`,
2253
+ )
2254
+ .all(limit) as CronRun[];
2255
+ }
2256
+
2257
+ export function getCronRunsByJob(db: Database, jobName: string, limit = 50): CronRun[] {
2258
+ return db
2259
+ .query(
2260
+ `SELECT id, job_name, started_at, elapsed_ms, status, metrics_json, error
2261
+ FROM cron_runs
2262
+ WHERE job_name = ?
2263
+ ORDER BY started_at DESC
2264
+ LIMIT ?`,
2265
+ )
2266
+ .all(jobName, limit) as CronRun[];
2267
+ }
2268
+
2269
+ // -- JSON parsing helpers -----------------------------------------------------
2270
+
2145
2271
  export function safeParseJsonArray<T = string>(json: string | null): T[] {
2146
2272
  if (!json) return [];
2147
2273
  try {
@@ -129,6 +129,22 @@ CREATE TABLE IF NOT EXISTS evolution_audit (
129
129
  validation_evidence_ref TEXT
130
130
  )`;
131
131
 
132
+ // -- Replay entry results (per-entry validation outcomes) ---------------------
133
+
134
+ export const CREATE_REPLAY_ENTRY_RESULTS = `
135
+ CREATE TABLE IF NOT EXISTS replay_entry_results (
136
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
137
+ proposal_id TEXT NOT NULL,
138
+ skill_name TEXT NOT NULL,
139
+ validation_mode TEXT NOT NULL,
140
+ phase TEXT NOT NULL,
141
+ query TEXT NOT NULL,
142
+ should_trigger INTEGER NOT NULL,
143
+ triggered INTEGER NOT NULL,
144
+ passed INTEGER NOT NULL,
145
+ evidence TEXT
146
+ )`;
147
+
132
148
  // -- Local telemetry tables (from JSONL logs) ---------------------------------
133
149
 
134
150
  export const CREATE_SESSION_TELEMETRY = `
@@ -294,6 +310,20 @@ CREATE TABLE IF NOT EXISTS commit_tracking (
294
310
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
295
311
  )`;
296
312
 
313
+ // -- Cron run audit log -------------------------------------------------------
314
+
315
+ export const CREATE_CRON_RUNS = `
316
+ CREATE TABLE IF NOT EXISTS cron_runs (
317
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
318
+ job_name TEXT NOT NULL,
319
+ started_at TEXT NOT NULL,
320
+ elapsed_ms INTEGER NOT NULL,
321
+ status TEXT NOT NULL,
322
+ metrics_json TEXT,
323
+ error TEXT,
324
+ UNIQUE(job_name, started_at)
325
+ )`;
326
+
297
327
  // -- Metadata table -----------------------------------------------------------
298
328
 
299
329
  export const CREATE_META = `
@@ -355,11 +385,17 @@ export const CREATE_INDEXES = [
355
385
  `CREATE INDEX IF NOT EXISTS idx_staging_kind ON canonical_upload_staging(record_kind)`,
356
386
  `CREATE INDEX IF NOT EXISTS idx_staging_session ON canonical_upload_staging(session_id)`,
357
387
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_staging_dedup ON canonical_upload_staging(record_kind, record_id)`,
388
+ // -- Replay entry result indexes ---------------------------------------------
389
+ `CREATE INDEX IF NOT EXISTS idx_replay_entry_proposal ON replay_entry_results(proposal_id)`,
390
+ `CREATE INDEX IF NOT EXISTS idx_replay_entry_skill ON replay_entry_results(skill_name)`,
391
+ `CREATE INDEX IF NOT EXISTS idx_replay_entry_passed ON replay_entry_results(passed)`,
358
392
  // -- Commit tracking indexes ------------------------------------------------
359
393
  `CREATE INDEX IF NOT EXISTS idx_commit_sha ON commit_tracking(commit_sha)`,
360
394
  `CREATE INDEX IF NOT EXISTS idx_commit_session ON commit_tracking(session_id)`,
361
395
  `CREATE INDEX IF NOT EXISTS idx_commit_ts ON commit_tracking(timestamp)`,
362
396
  `CREATE UNIQUE INDEX IF NOT EXISTS idx_commit_dedup ON commit_tracking(session_id, commit_sha)`,
397
+ // -- Cron run indexes -------------------------------------------------------
398
+ `CREATE INDEX IF NOT EXISTS idx_cron_runs_job_ts ON cron_runs(job_name, started_at)`,
363
399
  ];
364
400
 
365
401
  /**
@@ -443,6 +479,7 @@ export const ALL_DDL = [
443
479
  CREATE_EXECUTION_FACTS,
444
480
  CREATE_EVOLUTION_EVIDENCE,
445
481
  CREATE_EVOLUTION_AUDIT,
482
+ CREATE_REPLAY_ENTRY_RESULTS,
446
483
  CREATE_SESSION_TELEMETRY,
447
484
  CREATE_SKILL_USAGE,
448
485
  CREATE_ORCHESTRATE_RUNS,
@@ -454,6 +491,7 @@ export const ALL_DDL = [
454
491
  CREATE_UPLOAD_WATERMARKS,
455
492
  CREATE_CANONICAL_UPLOAD_STAGING,
456
493
  CREATE_COMMIT_TRACKING,
494
+ CREATE_CRON_RUNS,
457
495
  CREATE_META,
458
496
  ...CREATE_INDEXES,
459
497
  ];
@@ -26,7 +26,14 @@ import type {
26
26
  } from "./types.js";
27
27
  import { missingClaudeCodeHookKeys } from "./utils/hooks.js";
28
28
 
29
- const VALID_AGENT_TYPES = new Set(["claude_code", "codex", "opencode", "openclaw", "unknown"]);
29
+ const VALID_AGENT_TYPES = new Set([
30
+ "claude_code",
31
+ "codex",
32
+ "opencode",
33
+ "openclaw",
34
+ "pi",
35
+ "unknown",
36
+ ]);
30
37
  const VALID_LLM_MODES = new Set(["agent"]);
31
38
 
32
39
  const LOG_FILES: Record<string, string> = {
@@ -29,6 +29,7 @@ import { readGradingResultsForSkill } from "./grading/results.js";
29
29
  import { getDb } from "./localdb/db.js";
30
30
  import {
31
31
  updateSignalConsumed,
32
+ writeCronRunToDb,
32
33
  writeGradingResultToDb,
33
34
  writeOrchestrateRunToDb,
34
35
  } from "./localdb/direct-write.js";
@@ -1183,6 +1184,33 @@ export async function orchestrate(
1183
1184
  /* fail-open */
1184
1185
  }
1185
1186
 
1187
+ // Also log to unified cron_runs timeline
1188
+ const totalLlmCalls = candidates.reduce(
1189
+ (sum, c) => sum + (c.evolveResult?.llmCallCount ?? 0),
1190
+ 0,
1191
+ );
1192
+ try {
1193
+ writeCronRunToDb(getDb(), {
1194
+ jobName: "orchestrate",
1195
+ startedAt: runReport.timestamp,
1196
+ elapsedMs: runReport.elapsed_ms,
1197
+ status: "success",
1198
+ metrics: {
1199
+ total_skills: finalTotals.totalSkills,
1200
+ evaluated: finalTotals.evaluated,
1201
+ evolved: finalTotals.evolved,
1202
+ deployed: finalTotals.deployed,
1203
+ watched: finalTotals.watched,
1204
+ skipped: finalTotals.skipped,
1205
+ dry_run: result.summary.dryRun,
1206
+ total_llm_calls: totalLlmCalls,
1207
+ auto_graded: finalTotals.autoGraded,
1208
+ },
1209
+ });
1210
+ } catch {
1211
+ /* fail-open */
1212
+ }
1213
+
1186
1214
  // -------------------------------------------------------------------------
1187
1215
  // Step 9: Alpha upload (fail-open — never blocks the orchestrate loop)
1188
1216
  // -------------------------------------------------------------------------
@@ -1211,6 +1239,21 @@ export async function orchestrate(
1211
1239
  }
1212
1240
 
1213
1241
  return result;
1242
+ } catch (err) {
1243
+ // Log failed orchestrate run to unified cron_runs timeline
1244
+ const elapsedMs = Date.now() - startTime;
1245
+ try {
1246
+ writeCronRunToDb(getDb(), {
1247
+ jobName: "orchestrate",
1248
+ startedAt: new Date(startTime).toISOString(),
1249
+ elapsedMs,
1250
+ status: "error",
1251
+ error: err instanceof Error ? err.message : String(err),
1252
+ });
1253
+ } catch {
1254
+ /* fail-open */
1255
+ }
1256
+ throw err;
1214
1257
  } finally {
1215
1258
  releaseLock();
1216
1259
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Registry HTTP client. Never throws — returns typed results.
3
+ */
4
+
5
+ import { getSelftuneVersion } from "../utils/selftune-meta.js";
6
+
7
+ export interface RegistryResult<T = unknown> {
8
+ success: boolean;
9
+ data?: T;
10
+ error?: string;
11
+ status?: number;
12
+ }
13
+
14
+ function getConfig(): { apiUrl: string; apiKey: string } | null {
15
+ try {
16
+ const configPath = `${process.env.HOME}/.selftune/config.json`;
17
+ const raw = require("fs").readFileSync(configPath, "utf-8");
18
+ const config = JSON.parse(raw);
19
+ const apiUrl = config?.alpha?.cloud_api_url || "https://api.selftune.dev";
20
+ const apiKey = config?.alpha?.api_key;
21
+ if (!apiKey) return null;
22
+ return { apiUrl, apiKey };
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export async function registryRequest<T>(
29
+ method: string,
30
+ path: string,
31
+ opts?: { body?: unknown; formData?: FormData },
32
+ ): Promise<RegistryResult<T>> {
33
+ const config = getConfig();
34
+ if (!config) {
35
+ return { success: false, error: "Not authenticated. Run 'selftune alpha upload' to set up." };
36
+ }
37
+
38
+ try {
39
+ const headers: Record<string, string> = {
40
+ Authorization: `Bearer ${config.apiKey}`,
41
+ "User-Agent": `selftune/${getSelftuneVersion()}`,
42
+ };
43
+
44
+ let fetchBody: BodyInit | undefined;
45
+ if (opts?.formData) {
46
+ fetchBody = opts.formData;
47
+ // Don't set Content-Type — fetch sets multipart boundary automatically
48
+ } else if (opts?.body) {
49
+ headers["Content-Type"] = "application/json";
50
+ fetchBody = JSON.stringify(opts.body);
51
+ }
52
+
53
+ const response = await fetch(`${config.apiUrl}/api/v1/registry${path}`, {
54
+ method,
55
+ headers,
56
+ body: fetchBody,
57
+ signal: AbortSignal.timeout(60_000),
58
+ });
59
+
60
+ const text = await response.text();
61
+ if (!response.ok) {
62
+ return {
63
+ success: false,
64
+ error: `HTTP ${response.status}: ${text.slice(0, 300)}`,
65
+ status: response.status,
66
+ };
67
+ }
68
+
69
+ const data = text ? JSON.parse(text) : {};
70
+ return { success: true, data: data as T, status: response.status };
71
+ } catch (err) {
72
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
73
+ }
74
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * selftune registry history — Show version timeline for a skill.
3
+ */
4
+
5
+ import { registryRequest } from "./client.js";
6
+
7
+ export async function cliMain() {
8
+ const args = process.argv.slice(2);
9
+ const name = args.find((a) => !a.startsWith("--"));
10
+
11
+ if (!name) {
12
+ console.error(JSON.stringify({ error: "Usage: selftune registry history <name>" }));
13
+ process.exit(1);
14
+ }
15
+
16
+ const listResult = await registryRequest<{ entries: Array<{ id: string }> }>(
17
+ "GET",
18
+ `?name=${encodeURIComponent(name)}`,
19
+ );
20
+ if (!listResult.success || !listResult.data?.entries?.length) {
21
+ console.error(JSON.stringify({ error: `Skill '${name}' not found in registry` }));
22
+ process.exit(1);
23
+ }
24
+
25
+ const entryId = listResult.data.entries[0].id;
26
+ const result = await registryRequest<{
27
+ versions: Array<{
28
+ version: string;
29
+ is_current: boolean;
30
+ rolled_back: boolean;
31
+ aggregate_pass_rate: number | null;
32
+ aggregate_sessions: number;
33
+ change_summary: string | null;
34
+ pushed_at: string;
35
+ }>;
36
+ }>("GET", `/${entryId}/versions`);
37
+
38
+ if (!result.success) {
39
+ console.error(JSON.stringify({ error: result.error }));
40
+ process.exit(1);
41
+ }
42
+
43
+ const versions = result.data?.versions || [];
44
+ const timeline = versions.map((v) => ({
45
+ version: v.version,
46
+ status: v.is_current ? "current" : v.rolled_back ? "rolled_back" : "previous",
47
+ pass_rate: v.aggregate_pass_rate,
48
+ sessions: v.aggregate_sessions,
49
+ summary: v.change_summary,
50
+ pushed_at: v.pushed_at,
51
+ }));
52
+
53
+ console.log(JSON.stringify({ name, versions: timeline }));
54
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * selftune registry — Team skill distribution.
3
+ *
4
+ * Subcommands:
5
+ * push Push current skill folder as a new version
6
+ * install Download and install a skill from the registry
7
+ * sync Check for updates and pull latest versions
8
+ * status Show installed entries and version drift
9
+ * rollback Rollback a skill to a previous version
10
+ * history Show version timeline for a skill
11
+ * list Show all published entries in the org
12
+ */
13
+
14
+ import { CLIError } from "../utils/cli-error.js";
15
+
16
+ const sub = process.argv[2];
17
+
18
+ export async function cliMain() {
19
+ if (!sub || sub === "--help" || sub === "-h") {
20
+ console.log(`selftune registry — Team skill distribution
21
+
22
+ Usage:
23
+ selftune registry <subcommand> [options]
24
+
25
+ Subcommands:
26
+ push [name] Push current skill folder as a new version
27
+ install <name> Download and install a skill from the registry
28
+ sync Check for updates and pull latest versions
29
+ status Show installed entries and version drift
30
+ rollback <name> Rollback to a previous version
31
+ history <name> Show version timeline
32
+ list Show all published entries
33
+
34
+ Options:
35
+ --version=<semver> Set version explicitly (push)
36
+ --summary=<text> Change summary (push)
37
+ --global Install to ~/.claude/skills/ (install)
38
+ --to=<version> Target version (rollback)
39
+ --reason=<text> Rollback reason (rollback)
40
+ `);
41
+ return;
42
+ }
43
+
44
+ // Strip 'registry' from argv so subcommands see the right args
45
+ process.argv = [process.argv[0], process.argv[1], ...process.argv.slice(3)];
46
+
47
+ switch (sub) {
48
+ case "push": {
49
+ const { cliMain } = await import("./push.js");
50
+ await cliMain();
51
+ break;
52
+ }
53
+ case "install": {
54
+ const { cliMain } = await import("./install.js");
55
+ await cliMain();
56
+ break;
57
+ }
58
+ case "sync": {
59
+ const { cliMain } = await import("./sync.js");
60
+ await cliMain();
61
+ break;
62
+ }
63
+ case "status": {
64
+ const { cliMain } = await import("./status.js");
65
+ await cliMain();
66
+ break;
67
+ }
68
+ case "rollback": {
69
+ const { cliMain } = await import("./rollback.js");
70
+ await cliMain();
71
+ break;
72
+ }
73
+ case "history": {
74
+ const { cliMain } = await import("./history.js");
75
+ await cliMain();
76
+ break;
77
+ }
78
+ case "list": {
79
+ const { cliMain } = await import("./list.js");
80
+ await cliMain();
81
+ break;
82
+ }
83
+ default:
84
+ throw new CLIError(
85
+ `Unknown registry subcommand: ${sub}`,
86
+ "UNKNOWN_COMMAND",
87
+ "selftune registry --help",
88
+ );
89
+ }
90
+ }