selftune 0.2.21 → 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 (108) hide show
  1. package/README.md +15 -8
  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/cline/hook.ts +167 -0
  7. package/cli/selftune/adapters/cline/install.ts +197 -0
  8. package/cli/selftune/adapters/codex/hook.ts +296 -0
  9. package/cli/selftune/adapters/codex/install.ts +289 -0
  10. package/cli/selftune/adapters/opencode/hook.ts +222 -0
  11. package/cli/selftune/adapters/opencode/install.ts +543 -0
  12. package/cli/selftune/adapters/pi/hook.ts +273 -0
  13. package/cli/selftune/adapters/pi/install.ts +207 -0
  14. package/cli/selftune/constants.ts +10 -1
  15. package/cli/selftune/dashboard-contract.ts +14 -0
  16. package/cli/selftune/evolution/engines/judge-engine.ts +96 -0
  17. package/cli/selftune/evolution/engines/replay-engine.ts +158 -0
  18. package/cli/selftune/evolution/evidence.ts +2 -6
  19. package/cli/selftune/evolution/evolve-body.ts +73 -20
  20. package/cli/selftune/evolution/validate-body.ts +78 -42
  21. package/cli/selftune/evolution/validate-routing.ts +45 -104
  22. package/cli/selftune/hooks/auto-activate.ts +43 -37
  23. package/cli/selftune/hooks/skill-eval.ts +2 -1
  24. package/cli/selftune/hooks-shared/git-metadata.ts +149 -0
  25. package/cli/selftune/hooks-shared/hook-output.ts +105 -0
  26. package/cli/selftune/hooks-shared/normalize.ts +196 -0
  27. package/cli/selftune/hooks-shared/session-state.ts +76 -0
  28. package/cli/selftune/hooks-shared/skill-paths.ts +50 -0
  29. package/cli/selftune/hooks-shared/stdin-dispatch.ts +59 -0
  30. package/cli/selftune/hooks-shared/types.ts +91 -0
  31. package/cli/selftune/index.ts +76 -6
  32. package/cli/selftune/ingestors/pi-ingest.ts +726 -0
  33. package/cli/selftune/init.ts +11 -1
  34. package/cli/selftune/localdb/direct-write.ts +85 -0
  35. package/cli/selftune/localdb/materialize.ts +6 -7
  36. package/cli/selftune/localdb/queries.ts +126 -0
  37. package/cli/selftune/localdb/schema.ts +38 -0
  38. package/cli/selftune/observability.ts +8 -1
  39. package/cli/selftune/orchestrate.ts +43 -0
  40. package/cli/selftune/registry/client.ts +74 -0
  41. package/cli/selftune/registry/history.ts +54 -0
  42. package/cli/selftune/registry/index.ts +90 -0
  43. package/cli/selftune/registry/install.ts +141 -0
  44. package/cli/selftune/registry/list.ts +44 -0
  45. package/cli/selftune/registry/push.ts +171 -0
  46. package/cli/selftune/registry/rollback.ts +49 -0
  47. package/cli/selftune/registry/status.ts +62 -0
  48. package/cli/selftune/registry/sync.ts +125 -0
  49. package/cli/selftune/repair/skill-usage.ts +4 -1
  50. package/cli/selftune/status.ts +31 -0
  51. package/cli/selftune/sync.ts +127 -23
  52. package/cli/selftune/types.ts +2 -1
  53. package/cli/selftune/utils/jsonl.ts +1 -30
  54. package/cli/selftune/utils/llm-call.ts +99 -34
  55. package/cli/selftune/utils/skill-discovery.ts +22 -0
  56. package/node_modules/@selftune/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
  57. package/node_modules/@selftune/telemetry-contract/fixtures/golden.test.ts +0 -1
  58. package/node_modules/@selftune/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
  59. package/node_modules/@selftune/telemetry-contract/package.json +1 -1
  60. package/node_modules/@selftune/telemetry-contract/src/index.ts +1 -0
  61. package/node_modules/@selftune/telemetry-contract/src/schemas.ts +22 -4
  62. package/node_modules/@selftune/telemetry-contract/src/types.ts +1 -12
  63. package/node_modules/@selftune/telemetry-contract/tests/compatibility.test.ts +0 -1
  64. package/package.json +1 -1
  65. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +1 -1
  66. package/packages/telemetry-contract/fixtures/golden.test.ts +0 -1
  67. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +1 -1
  68. package/packages/telemetry-contract/package.json +1 -1
  69. package/packages/telemetry-contract/src/index.ts +1 -0
  70. package/packages/telemetry-contract/src/schemas.ts +22 -4
  71. package/packages/telemetry-contract/src/types.ts +1 -12
  72. package/packages/telemetry-contract/tests/compatibility.test.ts +0 -1
  73. package/packages/ui/AGENTS.md +16 -0
  74. package/packages/ui/README.md +1 -1
  75. package/packages/ui/package.json +1 -1
  76. package/packages/ui/src/components/ActivityTimeline.tsx +152 -168
  77. package/packages/ui/src/components/AnalyticsCharts.tsx +344 -0
  78. package/packages/ui/src/components/EvidenceViewer.tsx +153 -443
  79. package/packages/ui/src/components/EvolutionTimeline.tsx +34 -87
  80. package/packages/ui/src/components/InfoTip.tsx +1 -2
  81. package/packages/ui/src/components/InvocationsPanel.tsx +413 -0
  82. package/packages/ui/src/components/JobHistoryTimeline.tsx +156 -0
  83. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +18 -36
  84. package/packages/ui/src/components/OverviewPanels.tsx +652 -0
  85. package/packages/ui/src/components/PipelineStatusBar.tsx +65 -0
  86. package/packages/ui/src/components/SkillReportGuide.tsx +215 -0
  87. package/packages/ui/src/components/SkillReportPanels.tsx +919 -0
  88. package/packages/ui/src/components/SkillsLibrary.tsx +437 -0
  89. package/packages/ui/src/components/index.ts +56 -1
  90. package/packages/ui/src/components/section-cards.tsx +18 -35
  91. package/packages/ui/src/components/skill-health-grid.tsx +47 -37
  92. package/packages/ui/src/lib/constants.tsx +0 -1
  93. package/packages/ui/src/primitives/card.tsx +1 -1
  94. package/packages/ui/src/primitives/checkbox.tsx +1 -1
  95. package/packages/ui/src/primitives/dropdown-menu.tsx +2 -2
  96. package/packages/ui/src/primitives/select.tsx +2 -2
  97. package/packages/ui/src/types.ts +172 -4
  98. package/skill/SKILL.md +26 -2
  99. package/skill/Workflows/Ingest.md +60 -2
  100. package/skill/Workflows/Initialize.md +54 -9
  101. package/skill/Workflows/PlatformHooks.md +109 -0
  102. package/skill/Workflows/Registry.md +99 -0
  103. package/skill/Workflows/Sync.md +3 -1
  104. package/apps/local-dashboard/dist/assets/index-D8O-RG1I.js +0 -60
  105. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +0 -1
  106. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +0 -12
  107. package/cli/selftune/utils/html.ts +0 -27
  108. package/packages/ui/src/components/RecentActivityFeed.tsx +0 -117
@@ -0,0 +1,141 @@
1
+ /**
2
+ * selftune registry install — Download and extract a skill from the registry.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { mkdir, unlink, writeFile } from "node:fs/promises";
7
+ import { hostname } from "node:os";
8
+ import { join } from "node:path";
9
+
10
+ import { registryRequest } from "./client.js";
11
+
12
+ export async function cliMain() {
13
+ const args = process.argv.slice(2);
14
+ const name = args.find((a) => !a.startsWith("--"));
15
+ const globalFlag = args.includes("--global");
16
+
17
+ if (!name) {
18
+ console.error(
19
+ JSON.stringify({
20
+ error: "Usage: selftune registry install <name>",
21
+ guidance: { next_command: "selftune registry list" },
22
+ }),
23
+ );
24
+ process.exit(1);
25
+ }
26
+
27
+ // Find entry by name
28
+ const listResult = await registryRequest<{
29
+ entries: Array<{
30
+ id: string;
31
+ name: string;
32
+ current_version?: { id: string; version: string; content_hash: string };
33
+ }>;
34
+ }>("GET", `?name=${encodeURIComponent(name)}`);
35
+
36
+ if (!listResult.success || !listResult.data?.entries?.length) {
37
+ console.error(
38
+ JSON.stringify({
39
+ error: `Skill '${name}' not found in registry`,
40
+ guidance: { next_command: "selftune registry list" },
41
+ }),
42
+ );
43
+ process.exit(1);
44
+ }
45
+
46
+ const entry = listResult.data.entries[0];
47
+ const entryId = entry.id;
48
+
49
+ // Get detail with versions
50
+ const detailResult = await registryRequest<{
51
+ entry: { id: string; name: string };
52
+ versions: Array<{ id: string; version: string; content_hash: string; is_current: boolean }>;
53
+ }>("GET", `/${entryId}`);
54
+
55
+ if (!detailResult.success) {
56
+ console.error(JSON.stringify({ error: detailResult.error }));
57
+ process.exit(1);
58
+ }
59
+
60
+ const currentVersion = detailResult.data?.versions?.find((v) => v.is_current);
61
+ if (!currentVersion) {
62
+ console.error(JSON.stringify({ error: "No current version found" }));
63
+ process.exit(1);
64
+ }
65
+
66
+ // Request presigned download via sync
67
+ const syncResult = await registryRequest<{
68
+ entries: Array<{
69
+ download_url?: string;
70
+ latest_version: string;
71
+ latest_content_hash: string;
72
+ }>;
73
+ }>("POST", "/sync", {
74
+ body: { installations: [{ entry_id: entryId, current_version_hash: "none" }] },
75
+ });
76
+
77
+ const downloadUrl = syncResult.data?.entries?.[0]?.download_url;
78
+ if (!downloadUrl) {
79
+ console.error(JSON.stringify({ error: "Could not get download URL" }));
80
+ process.exit(1);
81
+ }
82
+
83
+ // Download archive
84
+ console.log(`Installing ${name} v${currentVersion.version}...`);
85
+ const response = await fetch(downloadUrl, { signal: AbortSignal.timeout(60_000) });
86
+ if (!response.ok) {
87
+ console.error(JSON.stringify({ error: `Download failed: HTTP ${response.status}` }));
88
+ process.exit(1);
89
+ }
90
+ const archiveBuffer = Buffer.from(await response.arrayBuffer());
91
+
92
+ // Determine install path
93
+ const targetBase = globalFlag
94
+ ? join(process.env.HOME || "~", ".claude", "skills")
95
+ : join(process.cwd(), ".claude", "skills");
96
+ const targetDir = join(targetBase, name);
97
+
98
+ // Extract archive
99
+ await mkdir(targetDir, { recursive: true });
100
+ const archivePath = `/tmp/selftune-install-${Date.now()}.tar.gz`;
101
+ await writeFile(archivePath, archiveBuffer);
102
+ const proc = Bun.spawn(["tar", "xzf", archivePath, "-C", targetDir], {
103
+ stdout: "ignore",
104
+ stderr: "pipe",
105
+ });
106
+ await proc.exited;
107
+
108
+ await unlink(archivePath).catch(() => {});
109
+
110
+ if (proc.exitCode !== 0) {
111
+ console.error(JSON.stringify({ error: "Failed to extract archive" }));
112
+ process.exit(1);
113
+ }
114
+
115
+ // Record installation on server
116
+ await registryRequest("POST", `/${entryId}/install`, {
117
+ body: { install_path: targetDir, device_id: hostname() },
118
+ });
119
+
120
+ // Update local state
121
+ const statePath = join(process.env.HOME || "~", ".selftune", "registry-state.json");
122
+ let state: Array<{ entryId: string; name: string; versionHash: string; installPath: string }> =
123
+ [];
124
+ try {
125
+ state = JSON.parse(readFileSync(statePath, "utf-8"));
126
+ } catch {}
127
+ state = state.filter((s) => s.entryId !== entryId);
128
+ state.push({ entryId, name, versionHash: currentVersion.content_hash, installPath: targetDir });
129
+ await mkdir(join(process.env.HOME || "~", ".selftune"), { recursive: true });
130
+ await writeFile(statePath, JSON.stringify(state, null, 2));
131
+
132
+ console.log(
133
+ JSON.stringify({
134
+ success: true,
135
+ name,
136
+ version: currentVersion.version,
137
+ path: targetDir,
138
+ global: globalFlag,
139
+ }),
140
+ );
141
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * selftune registry list — Show all published entries in the org.
3
+ */
4
+
5
+ import { registryRequest } from "./client.js";
6
+
7
+ export async function cliMain() {
8
+ const result = await registryRequest<{
9
+ entries: Array<{
10
+ name: string;
11
+ entry_type: string;
12
+ description: string | null;
13
+ current_version?: { version: string };
14
+ pass_rate: number | null;
15
+ eval_count: number;
16
+ }>;
17
+ }>("GET", "");
18
+
19
+ if (!result.success) {
20
+ console.error(JSON.stringify({ error: result.error }));
21
+ process.exit(1);
22
+ }
23
+
24
+ const entries = result.data?.entries || [];
25
+ if (entries.length === 0) {
26
+ console.log(
27
+ JSON.stringify({
28
+ message: "No entries in registry. Use 'selftune registry push' to publish a skill.",
29
+ }),
30
+ );
31
+ return;
32
+ }
33
+
34
+ const table = entries.map((e) => ({
35
+ name: e.name,
36
+ type: e.entry_type,
37
+ version: e.current_version?.version || "—",
38
+ pass_rate: e.pass_rate,
39
+ eval_count: e.eval_count,
40
+ description: e.description,
41
+ }));
42
+
43
+ console.log(JSON.stringify({ entries: table, total: entries.length }));
44
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * selftune registry push — Archive and upload a skill folder as a new version.
3
+ */
4
+
5
+ import { createHash } from "node:crypto";
6
+ import { readdir, readFile, stat, unlink } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+
9
+ import { registryRequest } from "./client.js";
10
+
11
+ interface FileManifestEntry {
12
+ path: string;
13
+ hash: string;
14
+ size: number;
15
+ }
16
+
17
+ async function collectFiles(
18
+ dir: string,
19
+ base?: string,
20
+ ): Promise<{ path: string; content: Buffer }[]> {
21
+ const files: { path: string; content: Buffer }[] = [];
22
+ const entries = await readdir(dir, { withFileTypes: true });
23
+ for (const entry of entries) {
24
+ const fullPath = join(dir, entry.name);
25
+ const relPath = base ? join(base, entry.name) : entry.name;
26
+ if (
27
+ entry.name === ".git" ||
28
+ entry.name === "node_modules" ||
29
+ entry.name === ".env" ||
30
+ entry.name.startsWith(".env.")
31
+ )
32
+ continue;
33
+ if (entry.isDirectory()) {
34
+ files.push(...(await collectFiles(fullPath, relPath)));
35
+ } else {
36
+ files.push({ path: relPath, content: await readFile(fullPath) });
37
+ }
38
+ }
39
+ return files;
40
+ }
41
+
42
+ export async function cliMain() {
43
+ const args = process.argv.slice(2);
44
+ const nameArg = args.find((a) => !a.startsWith("--"));
45
+ const versionFlag = args.find((a) => a.startsWith("--version="))?.slice("--version=".length);
46
+ const summaryFlag = args.find((a) => a.startsWith("--summary="))?.slice("--summary=".length);
47
+
48
+ // Find skill folder
49
+ const cwd = process.cwd();
50
+ const skillMd = join(cwd, "SKILL.md");
51
+ try {
52
+ await stat(skillMd);
53
+ } catch {
54
+ console.error(
55
+ JSON.stringify({
56
+ error: "No SKILL.md found in current directory. Navigate to a skill folder first.",
57
+ guidance: { next_command: "cd <skill-directory>" },
58
+ }),
59
+ );
60
+ process.exit(1);
61
+ }
62
+
63
+ // Read SKILL.md to extract name and description
64
+ const skillContent = await readFile(skillMd, "utf-8");
65
+ const nameMatch = skillContent.match(/^name:\s*(.+)$/m);
66
+ const descMatch = skillContent.match(/^description:\s*(.+)$/m);
67
+ const name = nameArg || nameMatch?.[1]?.trim() || "unnamed-skill";
68
+ const description = descMatch?.[1]?.trim() || "";
69
+
70
+ // Collect all files
71
+ const files = await collectFiles(cwd);
72
+ const manifest: FileManifestEntry[] = files.map((f) => ({
73
+ path: f.path,
74
+ hash: createHash("sha256").update(f.content).digest("hex"),
75
+ size: f.content.length,
76
+ }));
77
+
78
+ // Create tar.gz archive using system tar (available on all platforms)
79
+ const archivePath = `/tmp/selftune-registry-${Date.now()}.tar.gz`;
80
+ const proc = Bun.spawn(
81
+ [
82
+ "tar",
83
+ "czf",
84
+ archivePath,
85
+ "-C",
86
+ cwd,
87
+ "--exclude=.git",
88
+ "--exclude=node_modules",
89
+ "--exclude=.env",
90
+ "--exclude=.env.*",
91
+ ".",
92
+ ],
93
+ {
94
+ stdout: "ignore",
95
+ stderr: "pipe",
96
+ },
97
+ );
98
+ await proc.exited;
99
+ if (proc.exitCode !== 0) {
100
+ console.error(JSON.stringify({ error: "Failed to create archive" }));
101
+ process.exit(1);
102
+ }
103
+ const archiveBuffer = await readFile(archivePath);
104
+ const archiveHash = createHash("sha256").update(archiveBuffer).digest("hex");
105
+
106
+ // Clean up temp file
107
+ await unlink(archivePath).catch(() => {});
108
+
109
+ // Determine version
110
+ const version = versionFlag || `0.1.${Date.now()}`;
111
+
112
+ // Build multipart form
113
+ const formData = new FormData();
114
+ formData.append(
115
+ "metadata",
116
+ JSON.stringify({
117
+ name,
118
+ entry_type: "skill",
119
+ description,
120
+ version,
121
+ change_summary: summaryFlag || undefined,
122
+ file_manifest: manifest,
123
+ content_hash: archiveHash,
124
+ }),
125
+ );
126
+ formData.append(
127
+ "archive",
128
+ new Blob([archiveBuffer], { type: "application/gzip" }),
129
+ `${name}.tar.gz`,
130
+ );
131
+
132
+ // Try to push as new version first, fall back to create
133
+ console.log(
134
+ `Pushing ${name} v${version} (${(archiveBuffer.length / 1024).toFixed(1)} KB, ${files.length} files)...`,
135
+ );
136
+
137
+ // First check if entry exists
138
+ const listResult = await registryRequest<{ entries: Array<{ id: string; name: string }> }>(
139
+ "GET",
140
+ `?name=${encodeURIComponent(name)}`,
141
+ );
142
+
143
+ let result;
144
+ if (listResult.success && listResult.data?.entries?.length) {
145
+ const entryId = listResult.data.entries[0].id;
146
+ result = await registryRequest("POST", `/${entryId}/versions`, { formData });
147
+ } else {
148
+ result = await registryRequest("POST", "", { formData });
149
+ }
150
+
151
+ if (result.success) {
152
+ console.log(
153
+ JSON.stringify({
154
+ success: true,
155
+ name,
156
+ version,
157
+ files: files.length,
158
+ size: archiveBuffer.length,
159
+ hash: archiveHash,
160
+ }),
161
+ );
162
+ } else {
163
+ console.error(
164
+ JSON.stringify({
165
+ error: result.error,
166
+ guidance: { next_command: "selftune registry list" },
167
+ }),
168
+ );
169
+ process.exit(1);
170
+ }
171
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * selftune registry rollback — Rollback a skill to a previous version.
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
+ const toVersion = args.find((a) => a.startsWith("--to="))?.slice("--to=".length);
11
+ const reason = args.find((a) => a.startsWith("--reason="))?.slice("--reason=".length);
12
+
13
+ if (!name) {
14
+ console.error(
15
+ JSON.stringify({
16
+ error: "Usage: selftune registry rollback <name> [--to=version] [--reason=text]",
17
+ }),
18
+ );
19
+ process.exit(1);
20
+ }
21
+
22
+ // Find entry
23
+ const listResult = await registryRequest<{ entries: Array<{ id: string; name: string }> }>(
24
+ "GET",
25
+ `?name=${encodeURIComponent(name)}`,
26
+ );
27
+ if (!listResult.success || !listResult.data?.entries?.length) {
28
+ console.error(JSON.stringify({ error: `Skill '${name}' not found in registry` }));
29
+ process.exit(1);
30
+ }
31
+
32
+ const entryId = listResult.data.entries[0].id;
33
+ const result = await registryRequest("POST", `/${entryId}/rollback`, {
34
+ body: { target_version: toVersion, reason },
35
+ });
36
+
37
+ if (result.success) {
38
+ console.log(
39
+ JSON.stringify({
40
+ success: true,
41
+ name,
42
+ message: "Rolled back. Run 'selftune registry sync' to update local installations.",
43
+ }),
44
+ );
45
+ } else {
46
+ console.error(JSON.stringify({ error: result.error }));
47
+ process.exit(1);
48
+ }
49
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * selftune registry status — Show installed entries and version drift.
3
+ */
4
+
5
+ import { readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+
8
+ import { registryRequest } from "./client.js";
9
+
10
+ export async function cliMain() {
11
+ const statePath = join(process.env.HOME || "~", ".selftune", "registry-state.json");
12
+ let state: Array<{ entryId: string; name: string; versionHash: string; installPath: string }>;
13
+ try {
14
+ state = JSON.parse(await readFile(statePath, "utf-8"));
15
+ } catch {
16
+ console.log(JSON.stringify({ message: "No registry installations found." }));
17
+ return;
18
+ }
19
+
20
+ if (state.length === 0) {
21
+ console.log(JSON.stringify({ message: "No registry installations found." }));
22
+ return;
23
+ }
24
+
25
+ const syncResult = await registryRequest<{
26
+ entries: Array<{
27
+ entry_id: string;
28
+ name: string;
29
+ has_update: boolean;
30
+ latest_version: string;
31
+ current_version: string;
32
+ }>;
33
+ }>("POST", "/sync", {
34
+ body: {
35
+ installations: state.map((s) => ({
36
+ entry_id: s.entryId,
37
+ current_version_hash: s.versionHash,
38
+ })),
39
+ },
40
+ });
41
+
42
+ if (!syncResult.success) {
43
+ console.error(JSON.stringify({ error: syncResult.error }));
44
+ process.exit(1);
45
+ }
46
+
47
+ const entries = syncResult.data?.entries || [];
48
+ const table = entries.map((e) => ({
49
+ name: e.name,
50
+ installed: e.current_version,
51
+ latest: e.latest_version,
52
+ status: e.has_update ? "behind" : "up-to-date",
53
+ }));
54
+
55
+ console.log(
56
+ JSON.stringify({
57
+ installations: table,
58
+ total: state.length,
59
+ updates_available: entries.filter((e) => e.has_update).length,
60
+ }),
61
+ );
62
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * selftune registry sync — Check for updates and pull latest versions.
3
+ */
4
+
5
+ import { writeFile, readFile, mkdir, unlink } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+
8
+ import { registryRequest } from "./client.js";
9
+
10
+ interface LocalState {
11
+ entryId: string;
12
+ name: string;
13
+ versionHash: string;
14
+ installPath: string;
15
+ }
16
+
17
+ function getStatePath(): string {
18
+ return join(process.env.HOME || "~", ".selftune", "registry-state.json");
19
+ }
20
+
21
+ async function loadState(): Promise<LocalState[]> {
22
+ try {
23
+ const raw = await readFile(getStatePath(), "utf-8");
24
+ return JSON.parse(raw);
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ async function saveState(state: LocalState[]): Promise<void> {
31
+ await mkdir(join(process.env.HOME || "~", ".selftune"), { recursive: true });
32
+ await writeFile(getStatePath(), JSON.stringify(state, null, 2));
33
+ }
34
+
35
+ export async function cliMain() {
36
+ const state = await loadState();
37
+ if (state.length === 0) {
38
+ console.log(
39
+ JSON.stringify({
40
+ message: "No registry installations found. Use 'selftune registry install <name>' first.",
41
+ }),
42
+ );
43
+ return;
44
+ }
45
+
46
+ // Check for updates
47
+ const syncResult = await registryRequest<{
48
+ entries: Array<{
49
+ entry_id: string;
50
+ name: string;
51
+ has_update: boolean;
52
+ latest_version: string;
53
+ latest_content_hash: string;
54
+ download_url?: string;
55
+ }>;
56
+ }>("POST", "/sync", {
57
+ body: {
58
+ installations: state.map((s) => ({
59
+ entry_id: s.entryId,
60
+ current_version_hash: s.versionHash,
61
+ })),
62
+ },
63
+ });
64
+
65
+ if (!syncResult.success) {
66
+ console.error(JSON.stringify({ error: syncResult.error }));
67
+ process.exit(1);
68
+ }
69
+
70
+ const updates = syncResult.data?.entries?.filter((e) => e.has_update) || [];
71
+ if (updates.length === 0) {
72
+ console.log(JSON.stringify({ message: "All installations up to date", count: state.length }));
73
+ return;
74
+ }
75
+
76
+ console.log(`Found ${updates.length} update(s)...`);
77
+ let synced = 0;
78
+ let failed = 0;
79
+
80
+ for (const update of updates) {
81
+ if (!update.download_url) {
82
+ failed++;
83
+ continue;
84
+ }
85
+
86
+ const localEntry = state.find((s) => s.entryId === update.entry_id);
87
+ if (!localEntry) {
88
+ failed++;
89
+ continue;
90
+ }
91
+
92
+ try {
93
+ const response = await fetch(update.download_url, { signal: AbortSignal.timeout(60_000) });
94
+ if (!response.ok) {
95
+ failed++;
96
+ continue;
97
+ }
98
+
99
+ const archiveBuffer = Buffer.from(await response.arrayBuffer());
100
+ const archivePath = `/tmp/selftune-sync-${Date.now()}.tar.gz`;
101
+ await writeFile(archivePath, archiveBuffer);
102
+
103
+ // Extract to existing install path
104
+ const proc = Bun.spawn(["tar", "xzf", archivePath, "-C", localEntry.installPath], {
105
+ stdout: "ignore",
106
+ stderr: "pipe",
107
+ });
108
+ await proc.exited;
109
+ await unlink(archivePath).catch(() => {});
110
+
111
+ if (proc.exitCode === 0) {
112
+ localEntry.versionHash = update.latest_content_hash;
113
+ synced++;
114
+ console.log(` updated: ${update.name} -> v${update.latest_version}`);
115
+ } else {
116
+ failed++;
117
+ }
118
+ } catch {
119
+ failed++;
120
+ }
121
+ }
122
+
123
+ await saveState(state);
124
+ console.log(JSON.stringify({ synced, failed, total: state.length }));
125
+ }
@@ -31,6 +31,7 @@ import {
31
31
  findInstalledSkillPath,
32
32
  findRepositoryClaudeSkillDirs,
33
33
  findRepositorySkillDirs,
34
+ isTestFixturePath,
34
35
  } from "../utils/skill-discovery.js";
35
36
  import { writeRepairedSkillUsageRecords } from "../utils/skill-log.js";
36
37
 
@@ -380,7 +381,7 @@ function extractSessionSkillUsage(
380
381
 
381
382
  if (toolName === "Read") {
382
383
  const filePath = (input.file_path as string) ?? "";
383
- if (filePath.endsWith("SKILL.md")) {
384
+ if (filePath.endsWith("SKILL.md") && !isTestFixturePath(filePath)) {
384
385
  const inferredSkillName = basename(dirname(filePath)).trim();
385
386
  if (inferredSkillName && !skillPathLookup.has(inferredSkillName)) {
386
387
  skillPathLookup.set(inferredSkillName.toLowerCase(), filePath);
@@ -421,6 +422,8 @@ function extractSessionSkillUsage(
421
422
  ? { skillPath: knownSkillPath, resolutionSource: "raw_log" as const }
422
423
  : resolveClaudeSkillPath(skillName, sessionCwd, homeDir, codexHome);
423
424
 
425
+ if (isTestFixturePath(skillPath)) continue;
426
+
424
427
  const recordIndex =
425
428
  repaired.push({
426
429
  timestamp: timestamp || lastUserMessage.timestamp || fallbackTimestamp,
@@ -17,6 +17,7 @@ import { getQueueStats } from "./alpha-upload/queue.js";
17
17
  import { getBaseUrl } from "./auth/device-code.js";
18
18
  import { SELFTUNE_CONFIG_PATH } from "./constants.js";
19
19
  import { getDb } from "./localdb/db.js";
20
+ import { writeCronRunToDb } from "./localdb/direct-write.js";
20
21
  import {
21
22
  getLastUploadError,
22
23
  getLastUploadSuccess,
@@ -576,6 +577,8 @@ export function formatAlphaStatus(info: AlphaStatusInfo | null): string {
576
577
 
577
578
  export async function cliMain(): Promise<void> {
578
579
  const db = getDb();
580
+ const statusStartedAt = new Date();
581
+ const statusStart = performance.now();
579
582
  try {
580
583
  const telemetry = querySessionTelemetry(db) as SessionTelemetryRecord[];
581
584
  const skillRecords = querySkillUsageRecords(db) as SkillUsageRecord[];
@@ -610,9 +613,37 @@ export async function cliMain(): Promise<void> {
610
613
  }
611
614
  console.log(formatAlphaStatus(alphaInfo));
612
615
 
616
+ // Log cron run for unified timeline visibility
617
+ const statusElapsed = Math.round(performance.now() - statusStart);
618
+ writeCronRunToDb(db, {
619
+ jobName: "status",
620
+ startedAt: statusStartedAt.toISOString(),
621
+ elapsedMs: statusElapsed,
622
+ status: "success",
623
+ metrics: {
624
+ total_skills: result.skills.length,
625
+ healthy: result.skills.filter((s) => s.status === "HEALTHY").length,
626
+ warning: result.skills.filter((s) => s.status === "WARNING").length,
627
+ critical: result.skills.filter((s) => s.status === "CRITICAL").length,
628
+ system_healthy: result.system.healthy,
629
+ unmatched_queries: result.unmatchedQueries,
630
+ pending_proposals: result.pendingProposals,
631
+ },
632
+ });
633
+
613
634
  process.exit(0);
614
635
  } catch (err) {
636
+ // Log failed status run
637
+ const statusElapsed = Math.round(performance.now() - statusStart);
615
638
  const message = err instanceof Error ? err.message : String(err);
639
+ writeCronRunToDb(db, {
640
+ jobName: "status",
641
+ startedAt: statusStartedAt.toISOString(),
642
+ elapsedMs: statusElapsed,
643
+ status: "error",
644
+ error: message,
645
+ });
646
+
616
647
  console.error(`selftune status failed: ${message}`);
617
648
  process.exit(1);
618
649
  }