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.
- package/.claude/agents/diagnosis-analyst.md +146 -0
- package/.claude/agents/evolution-reviewer.md +167 -0
- package/.claude/agents/integration-guide.md +200 -0
- package/.claude/agents/pattern-analyst.md +147 -0
- package/CHANGELOG.md +38 -1
- package/README.md +96 -256
- package/assets/BeforeAfter.gif +0 -0
- package/assets/FeedbackLoop.gif +0 -0
- package/assets/logo.svg +9 -0
- package/assets/skill-health-badge.svg +20 -0
- package/cli/selftune/activation-rules.ts +171 -0
- package/cli/selftune/badge/badge-data.ts +108 -0
- package/cli/selftune/badge/badge-svg.ts +212 -0
- package/cli/selftune/badge/badge.ts +103 -0
- package/cli/selftune/constants.ts +75 -1
- package/cli/selftune/contribute/bundle.ts +314 -0
- package/cli/selftune/contribute/contribute.ts +214 -0
- package/cli/selftune/contribute/sanitize.ts +162 -0
- package/cli/selftune/cron/setup.ts +266 -0
- package/cli/selftune/dashboard-server.ts +582 -0
- package/cli/selftune/dashboard.ts +31 -12
- package/cli/selftune/eval/baseline.ts +247 -0
- package/cli/selftune/eval/composability.ts +117 -0
- package/cli/selftune/eval/generate-unit-tests.ts +143 -0
- package/cli/selftune/eval/hooks-to-evals.ts +68 -2
- package/cli/selftune/eval/import-skillsbench.ts +221 -0
- package/cli/selftune/eval/synthetic-evals.ts +172 -0
- package/cli/selftune/eval/unit-test-cli.ts +152 -0
- package/cli/selftune/eval/unit-test.ts +196 -0
- package/cli/selftune/evolution/deploy-proposal.ts +142 -1
- package/cli/selftune/evolution/evolve-body.ts +492 -0
- package/cli/selftune/evolution/evolve.ts +479 -104
- package/cli/selftune/evolution/extract-patterns.ts +32 -1
- package/cli/selftune/evolution/pareto.ts +314 -0
- package/cli/selftune/evolution/propose-body.ts +171 -0
- package/cli/selftune/evolution/propose-description.ts +100 -2
- package/cli/selftune/evolution/propose-routing.ts +166 -0
- package/cli/selftune/evolution/refine-body.ts +141 -0
- package/cli/selftune/evolution/rollback.ts +20 -3
- package/cli/selftune/evolution/validate-body.ts +254 -0
- package/cli/selftune/evolution/validate-proposal.ts +257 -35
- package/cli/selftune/evolution/validate-routing.ts +177 -0
- package/cli/selftune/grading/grade-session.ts +145 -19
- package/cli/selftune/grading/pre-gates.ts +104 -0
- package/cli/selftune/hooks/auto-activate.ts +185 -0
- package/cli/selftune/hooks/evolution-guard.ts +165 -0
- package/cli/selftune/hooks/skill-change-guard.ts +112 -0
- package/cli/selftune/index.ts +88 -0
- package/cli/selftune/ingestors/claude-replay.ts +351 -0
- package/cli/selftune/ingestors/codex-rollout.ts +1 -1
- package/cli/selftune/ingestors/openclaw-ingest.ts +440 -0
- package/cli/selftune/ingestors/opencode-ingest.ts +2 -2
- package/cli/selftune/init.ts +168 -5
- package/cli/selftune/last.ts +2 -2
- package/cli/selftune/memory/writer.ts +447 -0
- package/cli/selftune/monitoring/watch.ts +25 -2
- package/cli/selftune/status.ts +18 -15
- package/cli/selftune/types.ts +377 -5
- package/cli/selftune/utils/frontmatter.ts +217 -0
- package/cli/selftune/utils/llm-call.ts +29 -3
- package/cli/selftune/utils/transcript.ts +35 -0
- package/cli/selftune/utils/trigger-check.ts +89 -0
- package/cli/selftune/utils/tui.ts +156 -0
- package/dashboard/index.html +585 -19
- package/package.json +17 -6
- package/skill/SKILL.md +127 -10
- package/skill/Workflows/AutoActivation.md +144 -0
- package/skill/Workflows/Badge.md +118 -0
- package/skill/Workflows/Baseline.md +121 -0
- package/skill/Workflows/Composability.md +100 -0
- package/skill/Workflows/Contribute.md +91 -0
- package/skill/Workflows/Cron.md +155 -0
- package/skill/Workflows/Dashboard.md +203 -0
- package/skill/Workflows/Doctor.md +37 -1
- package/skill/Workflows/Evals.md +73 -5
- package/skill/Workflows/EvolutionMemory.md +152 -0
- package/skill/Workflows/Evolve.md +111 -6
- package/skill/Workflows/EvolveBody.md +159 -0
- package/skill/Workflows/ImportSkillsBench.md +111 -0
- package/skill/Workflows/Ingest.md +129 -15
- package/skill/Workflows/Initialize.md +58 -3
- package/skill/Workflows/Replay.md +70 -0
- package/skill/Workflows/Rollback.md +20 -1
- package/skill/Workflows/UnitTest.md +138 -0
- package/skill/Workflows/Watch.md +22 -0
- package/skill/settings_snippet.json +23 -0
- package/templates/activation-rules-default.json +27 -0
- package/templates/multi-skill-settings.json +64 -0
- 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
|
+
}
|