selftune 0.1.4 → 0.2.1

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 (153) hide show
  1. package/.claude/agents/diagnosis-analyst.md +156 -0
  2. package/.claude/agents/evolution-reviewer.md +180 -0
  3. package/.claude/agents/integration-guide.md +212 -0
  4. package/.claude/agents/pattern-analyst.md +160 -0
  5. package/CHANGELOG.md +46 -1
  6. package/README.md +105 -257
  7. package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  8. package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  9. package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  10. package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
  11. package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
  12. package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
  13. package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
  14. package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
  15. package/apps/local-dashboard/dist/favicon.png +0 -0
  16. package/apps/local-dashboard/dist/index.html +17 -0
  17. package/apps/local-dashboard/dist/logo.png +0 -0
  18. package/apps/local-dashboard/dist/logo.svg +9 -0
  19. package/assets/BeforeAfter.gif +0 -0
  20. package/assets/FeedbackLoop.gif +0 -0
  21. package/assets/logo.svg +9 -0
  22. package/assets/skill-health-badge.svg +20 -0
  23. package/cli/selftune/activation-rules.ts +171 -0
  24. package/cli/selftune/badge/badge-data.ts +108 -0
  25. package/cli/selftune/badge/badge-svg.ts +212 -0
  26. package/cli/selftune/badge/badge.ts +99 -0
  27. package/cli/selftune/canonical-export.ts +183 -0
  28. package/cli/selftune/constants.ts +103 -1
  29. package/cli/selftune/contribute/bundle.ts +314 -0
  30. package/cli/selftune/contribute/contribute.ts +214 -0
  31. package/cli/selftune/contribute/sanitize.ts +162 -0
  32. package/cli/selftune/cron/setup.ts +266 -0
  33. package/cli/selftune/dashboard-contract.ts +202 -0
  34. package/cli/selftune/dashboard-server.ts +1049 -0
  35. package/cli/selftune/dashboard.ts +43 -156
  36. package/cli/selftune/eval/baseline.ts +248 -0
  37. package/cli/selftune/eval/composability-v2.ts +273 -0
  38. package/cli/selftune/eval/composability.ts +117 -0
  39. package/cli/selftune/eval/generate-unit-tests.ts +143 -0
  40. package/cli/selftune/eval/hooks-to-evals.ts +101 -16
  41. package/cli/selftune/eval/import-skillsbench.ts +221 -0
  42. package/cli/selftune/eval/synthetic-evals.ts +172 -0
  43. package/cli/selftune/eval/unit-test-cli.ts +152 -0
  44. package/cli/selftune/eval/unit-test.ts +196 -0
  45. package/cli/selftune/evolution/deploy-proposal.ts +142 -1
  46. package/cli/selftune/evolution/evidence.ts +26 -0
  47. package/cli/selftune/evolution/evolve-body.ts +586 -0
  48. package/cli/selftune/evolution/evolve.ts +825 -116
  49. package/cli/selftune/evolution/extract-patterns.ts +105 -16
  50. package/cli/selftune/evolution/pareto.ts +314 -0
  51. package/cli/selftune/evolution/propose-body.ts +171 -0
  52. package/cli/selftune/evolution/propose-description.ts +100 -2
  53. package/cli/selftune/evolution/propose-routing.ts +166 -0
  54. package/cli/selftune/evolution/refine-body.ts +141 -0
  55. package/cli/selftune/evolution/rollback.ts +21 -4
  56. package/cli/selftune/evolution/validate-body.ts +254 -0
  57. package/cli/selftune/evolution/validate-proposal.ts +257 -35
  58. package/cli/selftune/evolution/validate-routing.ts +177 -0
  59. package/cli/selftune/grading/auto-grade.ts +200 -0
  60. package/cli/selftune/grading/grade-session.ts +513 -42
  61. package/cli/selftune/grading/pre-gates.ts +104 -0
  62. package/cli/selftune/grading/results.ts +42 -0
  63. package/cli/selftune/hooks/auto-activate.ts +185 -0
  64. package/cli/selftune/hooks/evolution-guard.ts +165 -0
  65. package/cli/selftune/hooks/prompt-log.ts +172 -2
  66. package/cli/selftune/hooks/session-stop.ts +123 -3
  67. package/cli/selftune/hooks/skill-change-guard.ts +112 -0
  68. package/cli/selftune/hooks/skill-eval.ts +119 -3
  69. package/cli/selftune/index.ts +415 -48
  70. package/cli/selftune/ingestors/claude-replay.ts +377 -0
  71. package/cli/selftune/ingestors/codex-rollout.ts +345 -46
  72. package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
  73. package/cli/selftune/ingestors/openclaw-ingest.ts +573 -0
  74. package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
  75. package/cli/selftune/init.ts +376 -16
  76. package/cli/selftune/last.ts +14 -5
  77. package/cli/selftune/localdb/db.ts +63 -0
  78. package/cli/selftune/localdb/materialize.ts +428 -0
  79. package/cli/selftune/localdb/queries.ts +376 -0
  80. package/cli/selftune/localdb/schema.ts +204 -0
  81. package/cli/selftune/memory/writer.ts +447 -0
  82. package/cli/selftune/monitoring/watch.ts +90 -16
  83. package/cli/selftune/normalization.ts +682 -0
  84. package/cli/selftune/observability.ts +19 -44
  85. package/cli/selftune/orchestrate.ts +1073 -0
  86. package/cli/selftune/quickstart.ts +203 -0
  87. package/cli/selftune/repair/skill-usage.ts +576 -0
  88. package/cli/selftune/schedule.ts +561 -0
  89. package/cli/selftune/status.ts +59 -33
  90. package/cli/selftune/sync.ts +627 -0
  91. package/cli/selftune/types.ts +525 -5
  92. package/cli/selftune/utils/canonical-log.ts +45 -0
  93. package/cli/selftune/utils/frontmatter.ts +217 -0
  94. package/cli/selftune/utils/hooks.ts +41 -0
  95. package/cli/selftune/utils/html.ts +27 -0
  96. package/cli/selftune/utils/llm-call.ts +103 -19
  97. package/cli/selftune/utils/math.ts +10 -0
  98. package/cli/selftune/utils/query-filter.ts +139 -0
  99. package/cli/selftune/utils/skill-discovery.ts +340 -0
  100. package/cli/selftune/utils/skill-log.ts +68 -0
  101. package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
  102. package/cli/selftune/utils/transcript.ts +307 -26
  103. package/cli/selftune/utils/trigger-check.ts +89 -0
  104. package/cli/selftune/utils/tui.ts +156 -0
  105. package/cli/selftune/workflows/discover.ts +254 -0
  106. package/cli/selftune/workflows/skill-md-writer.ts +288 -0
  107. package/cli/selftune/workflows/workflows.ts +188 -0
  108. package/package.json +28 -11
  109. package/packages/telemetry-contract/README.md +11 -0
  110. package/packages/telemetry-contract/fixtures/golden.json +87 -0
  111. package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
  112. package/packages/telemetry-contract/index.ts +1 -0
  113. package/packages/telemetry-contract/package.json +19 -0
  114. package/packages/telemetry-contract/src/index.ts +2 -0
  115. package/packages/telemetry-contract/src/types.ts +163 -0
  116. package/packages/telemetry-contract/src/validators.ts +109 -0
  117. package/skill/SKILL.md +180 -33
  118. package/skill/Workflows/AutoActivation.md +145 -0
  119. package/skill/Workflows/Badge.md +124 -0
  120. package/skill/Workflows/Baseline.md +144 -0
  121. package/skill/Workflows/Composability.md +107 -0
  122. package/skill/Workflows/Contribute.md +94 -0
  123. package/skill/Workflows/Cron.md +132 -0
  124. package/skill/Workflows/Dashboard.md +214 -0
  125. package/skill/Workflows/Doctor.md +63 -14
  126. package/skill/Workflows/Evals.md +110 -18
  127. package/skill/Workflows/EvolutionMemory.md +154 -0
  128. package/skill/Workflows/Evolve.md +181 -21
  129. package/skill/Workflows/EvolveBody.md +159 -0
  130. package/skill/Workflows/Grade.md +36 -31
  131. package/skill/Workflows/ImportSkillsBench.md +117 -0
  132. package/skill/Workflows/Ingest.md +142 -21
  133. package/skill/Workflows/Initialize.md +91 -23
  134. package/skill/Workflows/Orchestrate.md +139 -0
  135. package/skill/Workflows/Replay.md +91 -0
  136. package/skill/Workflows/Rollback.md +23 -4
  137. package/skill/Workflows/Schedule.md +61 -0
  138. package/skill/Workflows/Sync.md +88 -0
  139. package/skill/Workflows/UnitTest.md +150 -0
  140. package/skill/Workflows/Watch.md +33 -1
  141. package/skill/Workflows/Workflows.md +129 -0
  142. package/skill/assets/activation-rules-default.json +26 -0
  143. package/skill/assets/multi-skill-settings.json +63 -0
  144. package/skill/assets/single-skill-settings.json +57 -0
  145. package/skill/references/invocation-taxonomy.md +2 -2
  146. package/skill/references/logs.md +164 -2
  147. package/skill/references/setup-patterns.md +65 -0
  148. package/skill/references/version-history.md +40 -0
  149. package/skill/settings_snippet.json +23 -0
  150. package/templates/activation-rules-default.json +27 -0
  151. package/templates/multi-skill-settings.json +64 -0
  152. package/templates/single-skill-settings.json +58 -0
  153. package/dashboard/index.html +0 -1119
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * selftune contribute — opt-in export of anonymized skill observability data.
4
+ *
5
+ * Usage:
6
+ * bun run cli/selftune/contribute/contribute.ts --skill selftune [--preview] [--output file.json]
7
+ * bun run cli/selftune/contribute/contribute.ts --skill selftune --submit
8
+ */
9
+
10
+ import { spawnSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
12
+ import { parseArgs } from "node:util";
13
+ import { CONTRIBUTIONS_DIR } from "../constants.js";
14
+ import { assembleBundle } from "./bundle.js";
15
+ import { sanitizeBundle } from "./sanitize.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // CLI
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export async function cliMain(): Promise<void> {
22
+ const { values } = parseArgs({
23
+ options: {
24
+ skill: { type: "string", default: "selftune" },
25
+ output: { type: "string" },
26
+ preview: { type: "boolean", default: false },
27
+ sanitize: { type: "string", default: "conservative" },
28
+ since: { type: "string" },
29
+ submit: { type: "boolean", default: false },
30
+ endpoint: { type: "string", default: "https://selftune-api.fly.dev" },
31
+ github: { type: "boolean", default: false },
32
+ },
33
+ strict: true,
34
+ });
35
+
36
+ const skillName = values.skill ?? "selftune";
37
+ const sanitizationLevel = values.sanitize === "aggressive" ? "aggressive" : "conservative";
38
+
39
+ let since: Date | undefined;
40
+ if (values.since) {
41
+ since = new Date(values.since);
42
+ if (Number.isNaN(since.getTime())) {
43
+ console.error(
44
+ `Error: Invalid --since date: "${values.since}". Use a valid date format (e.g., 2026-01-01).`,
45
+ );
46
+ process.exit(1);
47
+ }
48
+ }
49
+
50
+ // 1. Assemble raw bundle
51
+ const rawBundle = assembleBundle({
52
+ skillName,
53
+ since,
54
+ sanitizationLevel,
55
+ });
56
+
57
+ // 2. Sanitize
58
+ const bundle = sanitizeBundle(rawBundle, sanitizationLevel, skillName);
59
+
60
+ // 3. Preview mode
61
+ if (values.preview) {
62
+ console.log(JSON.stringify(bundle, null, 2));
63
+ return;
64
+ }
65
+
66
+ // 4. Determine output path
67
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
68
+ const defaultPath = `${CONTRIBUTIONS_DIR}/selftune-contribution-${timestamp}.json`;
69
+ const outputPath = values.output ?? defaultPath;
70
+
71
+ // Ensure parent directory exists
72
+ const dir = outputPath.substring(0, outputPath.lastIndexOf("/"));
73
+ if (dir && !existsSync(dir)) {
74
+ mkdirSync(dir, { recursive: true });
75
+ }
76
+
77
+ // 5. Write
78
+ const json = JSON.stringify(bundle, null, 2);
79
+ writeFileSync(outputPath, json, "utf-8");
80
+
81
+ // 6. Summary
82
+ console.log(`Contribution bundle written to: ${outputPath}`);
83
+ console.log(` Queries: ${bundle.positive_queries.length}`);
84
+ console.log(` Eval entries: ${bundle.eval_entries.length}`);
85
+ console.log(` Sessions: ${bundle.session_metrics.total_sessions}`);
86
+ console.log(` Sanitization: ${sanitizationLevel}`);
87
+ if (bundle.grading_summary) {
88
+ console.log(
89
+ ` Grading: ${bundle.grading_summary.graded_sessions} sessions, ${(bundle.grading_summary.average_pass_rate * 100).toFixed(1)}% avg pass rate`,
90
+ );
91
+ }
92
+ if (bundle.evolution_summary) {
93
+ console.log(
94
+ ` Evolution: ${bundle.evolution_summary.total_proposals} proposals, ${bundle.evolution_summary.deployed_proposals} deployed`,
95
+ );
96
+ }
97
+
98
+ // 7. Submit
99
+ if (values.submit) {
100
+ if (values.github) {
101
+ const ok = submitToGitHub(json, outputPath);
102
+ if (!ok) process.exit(1);
103
+ } else {
104
+ const endpoint = values.endpoint ?? "https://selftune-api.fly.dev";
105
+ const ok = await submitToService(json, endpoint, skillName);
106
+ if (!ok) {
107
+ console.log("Falling back to GitHub submission...");
108
+ const ghOk = submitToGitHub(json, outputPath);
109
+ if (!ghOk) process.exit(1);
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Service submission
117
+ // ---------------------------------------------------------------------------
118
+
119
+ async function submitToService(
120
+ json: string,
121
+ endpoint: string,
122
+ skillName: string,
123
+ ): Promise<boolean> {
124
+ try {
125
+ const url = `${endpoint}/api/submit`;
126
+ const res = await fetch(url, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: json,
130
+ });
131
+
132
+ if (!res.ok) {
133
+ const body = await res.text();
134
+ console.error(`[ERROR] Service submission failed (${res.status}): ${body}`);
135
+ return false;
136
+ }
137
+
138
+ console.log(`\nSubmitted to ${endpoint}`);
139
+ console.log(` Badge: ${endpoint}/badge/${encodeURIComponent(skillName)}`);
140
+ console.log(` Report: ${endpoint}/report/${encodeURIComponent(skillName)}`);
141
+ return true;
142
+ } catch (err) {
143
+ console.error(
144
+ `[ERROR] Could not reach ${endpoint}: ${err instanceof Error ? err.message : String(err)}`,
145
+ );
146
+ return false;
147
+ }
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // GitHub submission
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function submitToGitHub(json: string, outputPath: string): boolean {
155
+ const repo = "selftune-dev/selftune";
156
+ const sizeKB = Buffer.byteLength(json, "utf-8") / 1024;
157
+
158
+ let body: string;
159
+ if (sizeKB < 50) {
160
+ body = `## Selftune Contribution\n\n\`\`\`json\n${json}\n\`\`\``;
161
+ } else {
162
+ // Create gist for large bundles
163
+ try {
164
+ const result = spawnSync("gh", ["gist", "create", outputPath, "--public"], {
165
+ encoding: "utf-8",
166
+ });
167
+ if (result.status !== 0) {
168
+ console.error("[ERROR] Failed to create gist. Is `gh` installed and authenticated?");
169
+ console.error(result.stderr || "gh gist create failed");
170
+ return false;
171
+ }
172
+ const gistUrl = result.stdout.trim();
173
+ body = `## Selftune Contribution\n\nBundle too large to inline (${sizeKB.toFixed(1)} KB).\n\nGist: ${gistUrl}`;
174
+ } catch (err) {
175
+ console.error("[ERROR] Failed to create gist. Is `gh` installed and authenticated?");
176
+ console.error(String(err));
177
+ return false;
178
+ }
179
+ }
180
+
181
+ try {
182
+ const result = spawnSync(
183
+ "gh",
184
+ [
185
+ "issue",
186
+ "create",
187
+ "--repo",
188
+ repo,
189
+ "--label",
190
+ "contribution",
191
+ "--title",
192
+ "selftune contribution",
193
+ "--body",
194
+ body,
195
+ ],
196
+ { encoding: "utf-8" },
197
+ );
198
+ if (result.status !== 0) {
199
+ console.error("[ERROR] Failed to create GitHub issue. Is `gh` installed and authenticated?");
200
+ console.error(result.stderr || "gh issue create failed");
201
+ return false;
202
+ }
203
+ console.log(`\nSubmitted: ${result.stdout.trim()}`);
204
+ return true;
205
+ } catch (err) {
206
+ console.error("[ERROR] Failed to create GitHub issue. Is `gh` installed and authenticated?");
207
+ console.error(String(err));
208
+ return false;
209
+ }
210
+ }
211
+
212
+ if (import.meta.main) {
213
+ await cliMain();
214
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Privacy sanitization for contribution bundles.
3
+ *
4
+ * Two levels:
5
+ * conservative (default) — redacts paths, emails, secrets, IPs, project names, session IDs
6
+ * aggressive — extends conservative with identifiers, quoted strings, modules, truncation
7
+ *
8
+ * All functions are pure (no side effects).
9
+ */
10
+
11
+ import {
12
+ AGGRESSIVE_MAX_QUERY_LENGTH,
13
+ EMAIL_PATTERN,
14
+ FILE_PATH_PATTERN,
15
+ IDENTIFIER_PATTERN,
16
+ IP_PATTERN,
17
+ MODULE_PATTERN,
18
+ SECRET_PATTERNS,
19
+ } from "../constants.js";
20
+ import type { ContributionBundle } from "../types.js";
21
+
22
+ // UUID v4 pattern for session ID redaction
23
+ const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
24
+
25
+ // Quoted string patterns for aggressive mode
26
+ const DOUBLE_QUOTED_PATTERN = /"[^"]*"/g;
27
+ const SINGLE_QUOTED_PATTERN = /'[^']*'/g;
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Conservative sanitization
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export function sanitizeConservative(text: string, projectName?: string): string {
34
+ if (!text) return text;
35
+
36
+ let result = text;
37
+
38
+ // Secrets first (longest/most specific patterns)
39
+ for (const pattern of SECRET_PATTERNS) {
40
+ // Clone regex to reset lastIndex
41
+ result = result.replace(new RegExp(pattern.source, pattern.flags), "[SECRET]");
42
+ }
43
+
44
+ // File paths
45
+ result = result.replace(new RegExp(FILE_PATH_PATTERN.source, FILE_PATH_PATTERN.flags), "[PATH]");
46
+
47
+ // Emails
48
+ result = result.replace(new RegExp(EMAIL_PATTERN.source, EMAIL_PATTERN.flags), "[EMAIL]");
49
+
50
+ // IPs
51
+ result = result.replace(new RegExp(IP_PATTERN.source, IP_PATTERN.flags), "[IP]");
52
+
53
+ // Project name
54
+ if (projectName) {
55
+ result = result.replace(new RegExp(escapeRegExp(projectName), "g"), "[PROJECT]");
56
+ }
57
+
58
+ // Session IDs (UUIDs)
59
+ result = result.replace(UUID_PATTERN, "[SESSION]");
60
+
61
+ return result;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Aggressive sanitization
66
+ // ---------------------------------------------------------------------------
67
+
68
+ export function sanitizeAggressive(text: string, projectName?: string): string {
69
+ if (!text) return text;
70
+
71
+ // Start with conservative
72
+ let result = sanitizeConservative(text, projectName);
73
+
74
+ // Module paths (import/require/from)
75
+ result = result.replace(new RegExp(MODULE_PATTERN.source, MODULE_PATTERN.flags), (match) => {
76
+ // Preserve the keyword, replace the path
77
+ const keyword = match.match(/^(import|require|from)/)?.[0] ?? "";
78
+ // Determine what follows the keyword
79
+ if (match.includes("(")) {
80
+ return `${keyword}([MODULE])`;
81
+ }
82
+ return `${keyword} [MODULE]`;
83
+ });
84
+
85
+ // Quoted strings
86
+ result = result.replace(DOUBLE_QUOTED_PATTERN, "[STRING]");
87
+ result = result.replace(SINGLE_QUOTED_PATTERN, "[STRING]");
88
+
89
+ // Long identifiers (camelCase/PascalCase > 8 chars)
90
+ result = result.replace(
91
+ new RegExp(IDENTIFIER_PATTERN.source, IDENTIFIER_PATTERN.flags),
92
+ "[IDENTIFIER]",
93
+ );
94
+
95
+ // Truncate
96
+ if (result.length > AGGRESSIVE_MAX_QUERY_LENGTH) {
97
+ result = result.slice(0, AGGRESSIVE_MAX_QUERY_LENGTH);
98
+ }
99
+
100
+ return result;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Dispatcher
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export function sanitize(
108
+ text: string,
109
+ level: "conservative" | "aggressive",
110
+ projectName?: string,
111
+ ): string {
112
+ return level === "aggressive"
113
+ ? sanitizeAggressive(text, projectName)
114
+ : sanitizeConservative(text, projectName);
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Bundle sanitization
119
+ // ---------------------------------------------------------------------------
120
+
121
+ export function sanitizeBundle(
122
+ bundle: ContributionBundle,
123
+ level: "conservative" | "aggressive",
124
+ projectName?: string,
125
+ ): ContributionBundle {
126
+ return {
127
+ ...bundle,
128
+ sanitization_level: level,
129
+ positive_queries: bundle.positive_queries.map((q) => ({
130
+ ...q,
131
+ query: sanitize(q.query, level, projectName),
132
+ })),
133
+ eval_entries: bundle.eval_entries.map((e) => ({
134
+ ...e,
135
+ query: sanitize(e.query, level, projectName),
136
+ })),
137
+ ...(bundle.unmatched_queries
138
+ ? {
139
+ unmatched_queries: bundle.unmatched_queries.map((q) => ({
140
+ ...q,
141
+ query: sanitize(q.query, level, projectName),
142
+ })),
143
+ }
144
+ : {}),
145
+ ...(bundle.pending_proposals
146
+ ? {
147
+ pending_proposals: bundle.pending_proposals.map((p) => ({
148
+ ...p,
149
+ details: sanitize(p.details, level, projectName),
150
+ })),
151
+ }
152
+ : {}),
153
+ };
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Helpers
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function escapeRegExp(str: string): string {
161
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
162
+ }
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * selftune cron — OpenClaw cron integration for selftune automation.
4
+ *
5
+ * Subcommands:
6
+ * setup Register default selftune cron jobs with OpenClaw
7
+ * list Show registered selftune cron jobs
8
+ * remove Remove all selftune cron jobs
9
+ *
10
+ * Usage:
11
+ * selftune cron setup [--dry-run] [--tz <timezone>]
12
+ * selftune cron list
13
+ * selftune cron remove [--dry-run]
14
+ */
15
+
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { parseArgs } from "node:util";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Types & constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface CronJobConfig {
26
+ name: string;
27
+ cron: string;
28
+ message: string;
29
+ description: string;
30
+ }
31
+
32
+ export const DEFAULT_CRON_JOBS: CronJobConfig[] = [
33
+ {
34
+ name: "selftune-sync",
35
+ cron: "*/30 * * * *",
36
+ message:
37
+ "Run selftune sync to replay and ingest new Claude Code, Codex, OpenCode, and OpenClaw source data, then rebuild the repaired skill-usage overlay.",
38
+ description: "Sync source-truth telemetry every 30 minutes",
39
+ },
40
+ {
41
+ name: "selftune-status",
42
+ cron: "0 8 * * *",
43
+ message:
44
+ "Run selftune sync first, then run selftune status --json and report any skills with pass rate below 80% or still ungraded due to sparse recent checks.",
45
+ description: "Daily health check after source sync",
46
+ },
47
+ {
48
+ name: "selftune-orchestrate",
49
+ cron: "0 */6 * * *",
50
+ message:
51
+ "Run selftune orchestrate --max-skills 3. This performs source-truth sync, selects candidate skills, evolves validated low-risk descriptions autonomously, and watches recent deployments for regressions.",
52
+ description: "Autonomous improvement loop every 6 hours",
53
+ },
54
+ ];
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers (exported for testability)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Build the argument array for `openclaw cron add`. */
61
+ export function buildCronAddArgs(job: CronJobConfig, tz: string): string[] {
62
+ return [
63
+ "cron",
64
+ "add",
65
+ "--name",
66
+ job.name,
67
+ "--cron",
68
+ job.cron,
69
+ "--tz",
70
+ tz,
71
+ "--session",
72
+ "isolated",
73
+ "--message",
74
+ job.message,
75
+ ];
76
+ }
77
+
78
+ /** Return the default path to OpenClaw's cron jobs file. */
79
+ export function getOpenClawJobsPath(): string {
80
+ return join(homedir(), ".openclaw", "cron", "jobs.json");
81
+ }
82
+
83
+ /** Type guard that validates all required CronJobConfig fields. */
84
+ function isCronJobConfig(value: unknown): value is CronJobConfig {
85
+ if (typeof value !== "object" || value === null) return false;
86
+ const obj = value as Record<string, unknown>;
87
+ return (
88
+ typeof obj.name === "string" &&
89
+ typeof obj.cron === "string" &&
90
+ typeof obj.message === "string" &&
91
+ typeof obj.description === "string"
92
+ );
93
+ }
94
+
95
+ /** Load cron jobs from a JSON file, filtering for selftune entries. */
96
+ export function loadCronJobs(jobsPath: string): CronJobConfig[] {
97
+ if (!existsSync(jobsPath)) {
98
+ return [];
99
+ }
100
+ try {
101
+ const raw = readFileSync(jobsPath, "utf-8");
102
+ const data = JSON.parse(raw);
103
+ if (!Array.isArray(data)) {
104
+ return [];
105
+ }
106
+ return data.filter((j: unknown) => isCronJobConfig(j) && j.name.startsWith("selftune-"));
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Subcommands
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /** Register default cron jobs with OpenClaw. */
117
+ export async function setupCronJobs(tz: string, dryRun: boolean): Promise<void> {
118
+ const openclawPath = Bun.which("openclaw");
119
+ if (!dryRun && !openclawPath) {
120
+ console.error("Error: openclaw is not installed or not in PATH.");
121
+ console.error("");
122
+ console.error("Install OpenClaw:");
123
+ console.error(" https://openclaw.dev/install");
124
+ console.error("");
125
+ console.error("Or ensure the openclaw binary is in your PATH.");
126
+ process.exit(1);
127
+ }
128
+
129
+ console.log(`Registering ${DEFAULT_CRON_JOBS.length} cron jobs (tz=${tz})...\n`);
130
+
131
+ for (const job of DEFAULT_CRON_JOBS) {
132
+ const args = buildCronAddArgs(job, tz);
133
+
134
+ if (dryRun) {
135
+ console.log(`[DRY RUN] openclaw ${args.join(" ")}`);
136
+ } else {
137
+ const proc = Bun.spawn(["openclaw", ...args], {
138
+ stdout: "inherit",
139
+ stderr: "inherit",
140
+ });
141
+ const exitCode = await proc.exited;
142
+ if (exitCode !== 0) {
143
+ console.error(
144
+ `Error: openclaw cron add failed for "${job.name}" with exit code ${exitCode}`,
145
+ );
146
+ process.exit(1);
147
+ }
148
+ console.log(` Registered: ${job.name} — ${job.description}`);
149
+ }
150
+ }
151
+
152
+ console.log("\nDone.");
153
+ }
154
+
155
+ /** Show registered selftune cron jobs. */
156
+ export function listCronJobs(): void {
157
+ const jobsPath = getOpenClawJobsPath();
158
+ const jobs = loadCronJobs(jobsPath);
159
+
160
+ if (jobs.length === 0) {
161
+ if (!existsSync(jobsPath)) {
162
+ console.log("No cron jobs file found at:", jobsPath);
163
+ } else {
164
+ console.log("No selftune cron jobs registered.");
165
+ }
166
+ return;
167
+ }
168
+
169
+ // Print as formatted table
170
+ const nameWidth = Math.max(20, ...jobs.map((j) => j.name.length));
171
+ const cronWidth = Math.max(16, ...jobs.map((j) => j.cron.length));
172
+
173
+ console.log(`${"NAME".padEnd(nameWidth)} ${"SCHEDULE".padEnd(cronWidth)} DESCRIPTION`);
174
+ console.log(`${"─".repeat(nameWidth)} ${"─".repeat(cronWidth)} ${"─".repeat(40)}`);
175
+
176
+ for (const job of jobs) {
177
+ console.log(`${job.name.padEnd(nameWidth)} ${job.cron.padEnd(cronWidth)} ${job.description}`);
178
+ }
179
+ }
180
+
181
+ /** Remove all selftune cron jobs from OpenClaw. */
182
+ export async function removeCronJobs(dryRun: boolean): Promise<void> {
183
+ const jobsPath = getOpenClawJobsPath();
184
+ const jobs = loadCronJobs(jobsPath);
185
+
186
+ if (jobs.length === 0) {
187
+ console.log("No selftune cron jobs to remove.");
188
+ return;
189
+ }
190
+
191
+ console.log(`Removing ${jobs.length} selftune cron jobs...\n`);
192
+
193
+ for (const job of jobs) {
194
+ if (dryRun) {
195
+ console.log(`[DRY RUN] openclaw cron remove --name ${job.name}`);
196
+ } else {
197
+ const proc = Bun.spawn(["openclaw", "cron", "remove", "--name", job.name], {
198
+ stdout: "inherit",
199
+ stderr: "inherit",
200
+ });
201
+ const exitCode = await proc.exited;
202
+ if (exitCode !== 0) {
203
+ console.error(
204
+ `Error: openclaw cron remove failed for "${job.name}" with exit code ${exitCode}`,
205
+ );
206
+ process.exit(1);
207
+ }
208
+ console.log(` Removed: ${job.name}`);
209
+ }
210
+ }
211
+
212
+ console.log("\nDone.");
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // CLI entry point
217
+ // ---------------------------------------------------------------------------
218
+
219
+ export async function cliMain(): Promise<void> {
220
+ const subcommand = process.argv[2];
221
+
222
+ const { values } = parseArgs({
223
+ options: {
224
+ "dry-run": { type: "boolean", default: false },
225
+ tz: { type: "string" },
226
+ },
227
+ strict: false,
228
+ allowPositionals: true,
229
+ });
230
+
231
+ // Get timezone: flag > env > system default
232
+ const tz = values.tz ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
233
+
234
+ switch (subcommand) {
235
+ case "setup":
236
+ await setupCronJobs(tz, values["dry-run"] ?? false);
237
+ break;
238
+ case "list":
239
+ listCronJobs();
240
+ break;
241
+ case "remove":
242
+ await removeCronJobs(values["dry-run"] ?? false);
243
+ break;
244
+ default:
245
+ console.log(`selftune cron — OpenClaw cron integration
246
+
247
+ Registers selftune automation jobs with OpenClaw's Gateway Scheduler.
248
+ This is an optional convenience for OpenClaw users. For generic scheduling
249
+ with system cron, launchd, or systemd, see: selftune schedule
250
+
251
+ Usage:
252
+ selftune cron setup [--dry-run] [--tz <timezone>]
253
+ selftune cron list
254
+ selftune cron remove [--dry-run]
255
+
256
+ Subcommands:
257
+ setup Register default selftune cron jobs with OpenClaw
258
+ list Show registered selftune cron jobs
259
+ remove Remove all selftune cron jobs`);
260
+ break;
261
+ }
262
+ }
263
+
264
+ if (import.meta.main) {
265
+ await cliMain();
266
+ }