selftune 0.1.2 → 0.2.0

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 (89) hide show
  1. package/.claude/agents/diagnosis-analyst.md +146 -0
  2. package/.claude/agents/evolution-reviewer.md +167 -0
  3. package/.claude/agents/integration-guide.md +200 -0
  4. package/.claude/agents/pattern-analyst.md +147 -0
  5. package/CHANGELOG.md +38 -1
  6. package/README.md +96 -256
  7. package/assets/BeforeAfter.gif +0 -0
  8. package/assets/FeedbackLoop.gif +0 -0
  9. package/assets/logo.svg +9 -0
  10. package/assets/skill-health-badge.svg +20 -0
  11. package/cli/selftune/activation-rules.ts +171 -0
  12. package/cli/selftune/badge/badge-data.ts +108 -0
  13. package/cli/selftune/badge/badge-svg.ts +212 -0
  14. package/cli/selftune/badge/badge.ts +103 -0
  15. package/cli/selftune/constants.ts +75 -1
  16. package/cli/selftune/contribute/bundle.ts +314 -0
  17. package/cli/selftune/contribute/contribute.ts +214 -0
  18. package/cli/selftune/contribute/sanitize.ts +162 -0
  19. package/cli/selftune/cron/setup.ts +266 -0
  20. package/cli/selftune/dashboard-server.ts +582 -0
  21. package/cli/selftune/dashboard.ts +31 -12
  22. package/cli/selftune/eval/baseline.ts +247 -0
  23. package/cli/selftune/eval/composability.ts +117 -0
  24. package/cli/selftune/eval/generate-unit-tests.ts +143 -0
  25. package/cli/selftune/eval/hooks-to-evals.ts +68 -2
  26. package/cli/selftune/eval/import-skillsbench.ts +221 -0
  27. package/cli/selftune/eval/synthetic-evals.ts +172 -0
  28. package/cli/selftune/eval/unit-test-cli.ts +152 -0
  29. package/cli/selftune/eval/unit-test.ts +196 -0
  30. package/cli/selftune/evolution/deploy-proposal.ts +142 -1
  31. package/cli/selftune/evolution/evolve-body.ts +492 -0
  32. package/cli/selftune/evolution/evolve.ts +479 -104
  33. package/cli/selftune/evolution/extract-patterns.ts +32 -1
  34. package/cli/selftune/evolution/pareto.ts +314 -0
  35. package/cli/selftune/evolution/propose-body.ts +171 -0
  36. package/cli/selftune/evolution/propose-description.ts +100 -2
  37. package/cli/selftune/evolution/propose-routing.ts +166 -0
  38. package/cli/selftune/evolution/refine-body.ts +141 -0
  39. package/cli/selftune/evolution/rollback.ts +20 -3
  40. package/cli/selftune/evolution/validate-body.ts +254 -0
  41. package/cli/selftune/evolution/validate-proposal.ts +257 -35
  42. package/cli/selftune/evolution/validate-routing.ts +177 -0
  43. package/cli/selftune/grading/grade-session.ts +145 -19
  44. package/cli/selftune/grading/pre-gates.ts +104 -0
  45. package/cli/selftune/hooks/auto-activate.ts +185 -0
  46. package/cli/selftune/hooks/evolution-guard.ts +165 -0
  47. package/cli/selftune/hooks/skill-change-guard.ts +112 -0
  48. package/cli/selftune/index.ts +88 -0
  49. package/cli/selftune/ingestors/claude-replay.ts +351 -0
  50. package/cli/selftune/ingestors/codex-rollout.ts +1 -1
  51. package/cli/selftune/ingestors/openclaw-ingest.ts +440 -0
  52. package/cli/selftune/ingestors/opencode-ingest.ts +2 -2
  53. package/cli/selftune/init.ts +168 -5
  54. package/cli/selftune/last.ts +2 -2
  55. package/cli/selftune/memory/writer.ts +447 -0
  56. package/cli/selftune/monitoring/watch.ts +25 -2
  57. package/cli/selftune/status.ts +18 -15
  58. package/cli/selftune/types.ts +377 -5
  59. package/cli/selftune/utils/frontmatter.ts +217 -0
  60. package/cli/selftune/utils/llm-call.ts +29 -3
  61. package/cli/selftune/utils/transcript.ts +35 -0
  62. package/cli/selftune/utils/trigger-check.ts +89 -0
  63. package/cli/selftune/utils/tui.ts +156 -0
  64. package/dashboard/index.html +585 -19
  65. package/package.json +17 -6
  66. package/skill/SKILL.md +127 -10
  67. package/skill/Workflows/AutoActivation.md +144 -0
  68. package/skill/Workflows/Badge.md +118 -0
  69. package/skill/Workflows/Baseline.md +121 -0
  70. package/skill/Workflows/Composability.md +100 -0
  71. package/skill/Workflows/Contribute.md +91 -0
  72. package/skill/Workflows/Cron.md +155 -0
  73. package/skill/Workflows/Dashboard.md +203 -0
  74. package/skill/Workflows/Doctor.md +37 -1
  75. package/skill/Workflows/Evals.md +73 -5
  76. package/skill/Workflows/EvolutionMemory.md +152 -0
  77. package/skill/Workflows/Evolve.md +111 -6
  78. package/skill/Workflows/EvolveBody.md +159 -0
  79. package/skill/Workflows/ImportSkillsBench.md +111 -0
  80. package/skill/Workflows/Ingest.md +129 -15
  81. package/skill/Workflows/Initialize.md +58 -3
  82. package/skill/Workflows/Replay.md +70 -0
  83. package/skill/Workflows/Rollback.md +20 -1
  84. package/skill/Workflows/UnitTest.md +138 -0
  85. package/skill/Workflows/Watch.md +22 -0
  86. package/skill/settings_snippet.json +23 -0
  87. package/templates/activation-rules-default.json +27 -0
  88. package/templates/multi-skill-settings.json +64 -0
  89. package/templates/single-skill-settings.json +58 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Bundle assembly for contribution export.
3
+ *
4
+ * Pure function: reads logs, filters, aggregates, and returns a ContributionBundle.
5
+ */
6
+
7
+ import { randomUUID } from "node:crypto";
8
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import {
12
+ EVOLUTION_AUDIT_LOG,
13
+ QUERY_LOG,
14
+ SELFTUNE_CONFIG_DIR,
15
+ SKILL_LOG,
16
+ TELEMETRY_LOG,
17
+ } from "../constants.js";
18
+ import { buildEvalSet, classifyInvocation } from "../eval/hooks-to-evals.js";
19
+ import type {
20
+ ContributionBundle,
21
+ ContributionEvolutionSummary,
22
+ ContributionGradingSummary,
23
+ ContributionQuery,
24
+ ContributionSessionMetrics,
25
+ EvolutionAuditEntry,
26
+ GradingResult,
27
+ QueryLogRecord,
28
+ SessionTelemetryRecord,
29
+ SkillUsageRecord,
30
+ } from "../types.js";
31
+ import { readJsonl } from "../utils/jsonl.js";
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Helpers
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function filterSince<T extends { timestamp: string }>(records: T[], since?: Date): T[] {
38
+ if (!since) return records;
39
+ return records.filter((r) => new Date(r.timestamp) >= since);
40
+ }
41
+
42
+ function getVersion(): string {
43
+ try {
44
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, "../../../package.json"), "utf-8"));
45
+ return pkg.version ?? "unknown";
46
+ } catch {
47
+ return "unknown";
48
+ }
49
+ }
50
+
51
+ function getAgentType(): string {
52
+ try {
53
+ const configPath = join(SELFTUNE_CONFIG_DIR, "config.json");
54
+ if (!existsSync(configPath)) return "unknown";
55
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
56
+ return config.agent_type ?? "unknown";
57
+ } catch {
58
+ return "unknown";
59
+ }
60
+ }
61
+
62
+ function avg(nums: number[]): number {
63
+ if (nums.length === 0) return 0;
64
+ return nums.reduce((a, b) => a + b, 0) / nums.length;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Grading summary
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function buildGradingSummary(skillName: string): ContributionGradingSummary | null {
72
+ const gradingDir = join(homedir(), ".selftune", "grading");
73
+ if (!existsSync(gradingDir)) return null;
74
+
75
+ try {
76
+ const files = readdirSync(gradingDir).filter((f) => f.endsWith(".json"));
77
+ if (files.length === 0) return null;
78
+
79
+ let totalSessions = 0;
80
+ let gradedSessions = 0;
81
+ let passRateSum = 0;
82
+ let expectationCount = 0;
83
+
84
+ for (const file of files) {
85
+ try {
86
+ const data = JSON.parse(readFileSync(join(gradingDir, file), "utf-8")) as GradingResult;
87
+ if (data.skill_name !== skillName) continue;
88
+ totalSessions++;
89
+ if (data.summary) {
90
+ gradedSessions++;
91
+ passRateSum += data.summary.pass_rate ?? 0;
92
+ expectationCount += data.summary.total ?? 0;
93
+ }
94
+ } catch {
95
+ // skip malformed grading files
96
+ }
97
+ }
98
+
99
+ if (gradedSessions === 0) return null;
100
+
101
+ return {
102
+ total_sessions: totalSessions,
103
+ graded_sessions: gradedSessions,
104
+ average_pass_rate: passRateSum / gradedSessions,
105
+ expectation_count: expectationCount,
106
+ };
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Evolution summary
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function buildEvolutionSummary(
117
+ records: EvolutionAuditEntry[],
118
+ ): ContributionEvolutionSummary | null {
119
+ if (records.length === 0) return null;
120
+
121
+ const proposals = new Set<string>();
122
+ let deployed = 0;
123
+ let rolledBack = 0;
124
+ const improvements: number[] = [];
125
+
126
+ for (const r of records) {
127
+ proposals.add(r.proposal_id);
128
+ if (r.action === "deployed") {
129
+ deployed++;
130
+ if (r.eval_snapshot?.pass_rate != null) {
131
+ improvements.push(r.eval_snapshot.pass_rate);
132
+ }
133
+ }
134
+ if (r.action === "rolled_back") {
135
+ rolledBack++;
136
+ }
137
+ }
138
+
139
+ return {
140
+ total_proposals: proposals.size,
141
+ deployed_proposals: deployed,
142
+ rolled_back_proposals: rolledBack,
143
+ average_improvement: improvements.length > 0 ? avg(improvements) : 0,
144
+ };
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Session metrics
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function buildSessionMetrics(records: SessionTelemetryRecord[]): ContributionSessionMetrics {
152
+ if (records.length === 0) {
153
+ return {
154
+ total_sessions: 0,
155
+ avg_assistant_turns: 0,
156
+ avg_tool_calls: 0,
157
+ avg_errors: 0,
158
+ top_tools: [],
159
+ };
160
+ }
161
+
162
+ const toolCounts = new Map<string, number>();
163
+ for (const r of records) {
164
+ for (const [tool, count] of Object.entries(r.tool_calls ?? {})) {
165
+ toolCounts.set(tool, (toolCounts.get(tool) ?? 0) + count);
166
+ }
167
+ }
168
+
169
+ const topTools = [...toolCounts.entries()]
170
+ .sort((a, b) => b[1] - a[1])
171
+ .slice(0, 10)
172
+ .map(([tool, count]) => ({ tool, count }));
173
+
174
+ return {
175
+ total_sessions: records.length,
176
+ avg_assistant_turns: Math.round(avg(records.map((r) => r.assistant_turns ?? 0))),
177
+ avg_tool_calls: Math.round(avg(records.map((r) => r.total_tool_calls ?? 0))),
178
+ avg_errors: Number(avg(records.map((r) => r.errors_encountered ?? 0)).toFixed(2)),
179
+ top_tools: topTools,
180
+ };
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Main assembly
185
+ // ---------------------------------------------------------------------------
186
+
187
+ export function assembleBundle(options: {
188
+ skillName: string;
189
+ since?: Date;
190
+ sanitizationLevel: "conservative" | "aggressive";
191
+ queryLogPath?: string;
192
+ skillLogPath?: string;
193
+ telemetryLogPath?: string;
194
+ evolutionAuditLogPath?: string;
195
+ }): ContributionBundle {
196
+ const {
197
+ skillName,
198
+ since,
199
+ sanitizationLevel,
200
+ queryLogPath = QUERY_LOG,
201
+ skillLogPath = SKILL_LOG,
202
+ telemetryLogPath = TELEMETRY_LOG,
203
+ evolutionAuditLogPath = EVOLUTION_AUDIT_LOG,
204
+ } = options;
205
+
206
+ // Read all logs
207
+ const allSkillRecords = readJsonl<SkillUsageRecord>(skillLogPath);
208
+ const allQueryRecords = readJsonl<QueryLogRecord>(queryLogPath);
209
+ const allTelemetryRecords = readJsonl<SessionTelemetryRecord>(telemetryLogPath);
210
+ const allEvolutionRecords = readJsonl<EvolutionAuditEntry>(evolutionAuditLogPath);
211
+
212
+ // Filter by skill and since
213
+ const skillRecords = filterSince(
214
+ allSkillRecords.filter((r) => r.skill_name === skillName),
215
+ since,
216
+ );
217
+ const queryRecords = filterSince(allQueryRecords, since);
218
+ const telemetryRecords = filterSince(
219
+ allTelemetryRecords.filter((r) => (r.skills_triggered ?? []).includes(skillName)),
220
+ since,
221
+ );
222
+ // TODO: Filter evolution records by skillName once EvolutionAuditEntry gains a skill_name field.
223
+ // Currently includes all skills' proposals. Schema change requires human review (escalation-policy.md).
224
+ const evolutionRecords = filterSince(allEvolutionRecords, since);
225
+
226
+ // Build positive queries
227
+ const seenQueries = new Set<string>();
228
+ const positiveQueries: ContributionQuery[] = [];
229
+ const triggeredQueryTexts = new Set<string>();
230
+ for (const r of skillRecords) {
231
+ const q = (r.query ?? "").trim();
232
+ if (r.triggered) triggeredQueryTexts.add(q);
233
+ if (!q || seenQueries.has(q)) continue;
234
+ seenQueries.add(q);
235
+ positiveQueries.push({
236
+ query: q,
237
+ invocation_type: classifyInvocation(q, skillName),
238
+ source: r.source ?? "skill_log",
239
+ });
240
+ }
241
+
242
+ // Build unmatched queries: queries with no matching triggered skill record
243
+ const unmatchedQueries = queryRecords
244
+ .filter((r) => !triggeredQueryTexts.has((r.query ?? "").trim()))
245
+ .map((r) => ({ query: (r.query ?? "").trim(), timestamp: r.timestamp }))
246
+ .filter((r) => r.query.length > 0);
247
+
248
+ // Build pending proposals: proposals with created/validated but no terminal action
249
+ const terminalActions = new Set(["deployed", "rejected", "rolled_back"]);
250
+ const proposalActions = new Map<string, EvolutionAuditEntry[]>();
251
+ for (const r of evolutionRecords) {
252
+ const entries = proposalActions.get(r.proposal_id) ?? [];
253
+ entries.push(r);
254
+ proposalActions.set(r.proposal_id, entries);
255
+ }
256
+ const pendingProposals: Array<{
257
+ proposal_id: string;
258
+ skill_name?: string;
259
+ action: string;
260
+ timestamp: string;
261
+ details: string;
262
+ }> = [];
263
+ for (const [proposalId, entries] of proposalActions) {
264
+ const hasTerminal = entries.some((e) => terminalActions.has(e.action));
265
+ if (!hasTerminal) {
266
+ // Use the latest entry for this proposal
267
+ const latest = entries[entries.length - 1];
268
+ pendingProposals.push({
269
+ proposal_id: proposalId,
270
+ skill_name: latest.skill_name,
271
+ action: latest.action,
272
+ timestamp: latest.timestamp,
273
+ details: latest.details,
274
+ });
275
+ }
276
+ }
277
+
278
+ // Build eval entries
279
+ const evalEntries = buildEvalSet(skillRecords, queryRecords, skillName, 50, true, 42, true).map(
280
+ (e) => ({
281
+ query: e.query,
282
+ should_trigger: e.should_trigger,
283
+ invocation_type: e.invocation_type,
284
+ }),
285
+ );
286
+
287
+ // Build grading summary
288
+ const gradingSummary = buildGradingSummary(skillName);
289
+
290
+ // Build evolution summary
291
+ const evolutionSummary = buildEvolutionSummary(evolutionRecords);
292
+
293
+ // Build session metrics
294
+ const sessionMetrics = buildSessionMetrics(telemetryRecords);
295
+
296
+ const hasNewFields = unmatchedQueries.length > 0 || pendingProposals.length > 0;
297
+
298
+ return {
299
+ schema_version: hasNewFields ? "1.2" : "1.1",
300
+ skill_name: skillName,
301
+ contributor_id: randomUUID(),
302
+ created_at: new Date().toISOString(),
303
+ selftune_version: getVersion(),
304
+ agent_type: getAgentType(),
305
+ sanitization_level: sanitizationLevel,
306
+ positive_queries: positiveQueries,
307
+ eval_entries: evalEntries,
308
+ grading_summary: gradingSummary,
309
+ evolution_summary: evolutionSummary,
310
+ session_metrics: sessionMetrics,
311
+ ...(unmatchedQueries.length > 0 ? { unmatched_queries: unmatchedQueries } : {}),
312
+ ...(pendingProposals.length > 0 ? { pending_proposals: pendingProposals } : {}),
313
+ };
314
+ }
@@ -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 = "WellDunDun/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
+ }