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.
- package/.claude/agents/diagnosis-analyst.md +156 -0
- package/.claude/agents/evolution-reviewer.md +180 -0
- package/.claude/agents/integration-guide.md +212 -0
- package/.claude/agents/pattern-analyst.md +160 -0
- package/CHANGELOG.md +46 -1
- package/README.md +105 -257
- package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
- package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
- package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
- package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
- package/apps/local-dashboard/dist/favicon.png +0 -0
- package/apps/local-dashboard/dist/index.html +17 -0
- package/apps/local-dashboard/dist/logo.png +0 -0
- package/apps/local-dashboard/dist/logo.svg +9 -0
- 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 +99 -0
- package/cli/selftune/canonical-export.ts +183 -0
- package/cli/selftune/constants.ts +103 -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-contract.ts +202 -0
- package/cli/selftune/dashboard-server.ts +1049 -0
- package/cli/selftune/dashboard.ts +43 -156
- package/cli/selftune/eval/baseline.ts +248 -0
- package/cli/selftune/eval/composability-v2.ts +273 -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 +101 -16
- 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/evidence.ts +26 -0
- package/cli/selftune/evolution/evolve-body.ts +586 -0
- package/cli/selftune/evolution/evolve.ts +825 -116
- package/cli/selftune/evolution/extract-patterns.ts +105 -16
- 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 +21 -4
- 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/auto-grade.ts +200 -0
- package/cli/selftune/grading/grade-session.ts +513 -42
- package/cli/selftune/grading/pre-gates.ts +104 -0
- package/cli/selftune/grading/results.ts +42 -0
- package/cli/selftune/hooks/auto-activate.ts +185 -0
- package/cli/selftune/hooks/evolution-guard.ts +165 -0
- package/cli/selftune/hooks/prompt-log.ts +172 -2
- package/cli/selftune/hooks/session-stop.ts +123 -3
- package/cli/selftune/hooks/skill-change-guard.ts +112 -0
- package/cli/selftune/hooks/skill-eval.ts +119 -3
- package/cli/selftune/index.ts +415 -48
- package/cli/selftune/ingestors/claude-replay.ts +377 -0
- package/cli/selftune/ingestors/codex-rollout.ts +345 -46
- package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
- package/cli/selftune/ingestors/openclaw-ingest.ts +573 -0
- package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
- package/cli/selftune/init.ts +376 -16
- package/cli/selftune/last.ts +14 -5
- package/cli/selftune/localdb/db.ts +63 -0
- package/cli/selftune/localdb/materialize.ts +428 -0
- package/cli/selftune/localdb/queries.ts +376 -0
- package/cli/selftune/localdb/schema.ts +204 -0
- package/cli/selftune/memory/writer.ts +447 -0
- package/cli/selftune/monitoring/watch.ts +90 -16
- package/cli/selftune/normalization.ts +682 -0
- package/cli/selftune/observability.ts +19 -44
- package/cli/selftune/orchestrate.ts +1073 -0
- package/cli/selftune/quickstart.ts +203 -0
- package/cli/selftune/repair/skill-usage.ts +576 -0
- package/cli/selftune/schedule.ts +561 -0
- package/cli/selftune/status.ts +59 -33
- package/cli/selftune/sync.ts +627 -0
- package/cli/selftune/types.ts +525 -5
- package/cli/selftune/utils/canonical-log.ts +45 -0
- package/cli/selftune/utils/frontmatter.ts +217 -0
- package/cli/selftune/utils/hooks.ts +41 -0
- package/cli/selftune/utils/html.ts +27 -0
- package/cli/selftune/utils/llm-call.ts +103 -19
- package/cli/selftune/utils/math.ts +10 -0
- package/cli/selftune/utils/query-filter.ts +139 -0
- package/cli/selftune/utils/skill-discovery.ts +340 -0
- package/cli/selftune/utils/skill-log.ts +68 -0
- package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
- package/cli/selftune/utils/transcript.ts +307 -26
- package/cli/selftune/utils/trigger-check.ts +89 -0
- package/cli/selftune/utils/tui.ts +156 -0
- package/cli/selftune/workflows/discover.ts +254 -0
- package/cli/selftune/workflows/skill-md-writer.ts +288 -0
- package/cli/selftune/workflows/workflows.ts +188 -0
- package/package.json +28 -11
- package/packages/telemetry-contract/README.md +11 -0
- package/packages/telemetry-contract/fixtures/golden.json +87 -0
- package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
- package/packages/telemetry-contract/index.ts +1 -0
- package/packages/telemetry-contract/package.json +19 -0
- package/packages/telemetry-contract/src/index.ts +2 -0
- package/packages/telemetry-contract/src/types.ts +163 -0
- package/packages/telemetry-contract/src/validators.ts +109 -0
- package/skill/SKILL.md +180 -33
- package/skill/Workflows/AutoActivation.md +145 -0
- package/skill/Workflows/Badge.md +124 -0
- package/skill/Workflows/Baseline.md +144 -0
- package/skill/Workflows/Composability.md +107 -0
- package/skill/Workflows/Contribute.md +94 -0
- package/skill/Workflows/Cron.md +132 -0
- package/skill/Workflows/Dashboard.md +214 -0
- package/skill/Workflows/Doctor.md +63 -14
- package/skill/Workflows/Evals.md +110 -18
- package/skill/Workflows/EvolutionMemory.md +154 -0
- package/skill/Workflows/Evolve.md +181 -21
- package/skill/Workflows/EvolveBody.md +159 -0
- package/skill/Workflows/Grade.md +36 -31
- package/skill/Workflows/ImportSkillsBench.md +117 -0
- package/skill/Workflows/Ingest.md +142 -21
- package/skill/Workflows/Initialize.md +91 -23
- package/skill/Workflows/Orchestrate.md +139 -0
- package/skill/Workflows/Replay.md +91 -0
- package/skill/Workflows/Rollback.md +23 -4
- package/skill/Workflows/Schedule.md +61 -0
- package/skill/Workflows/Sync.md +88 -0
- package/skill/Workflows/UnitTest.md +150 -0
- package/skill/Workflows/Watch.md +33 -1
- package/skill/Workflows/Workflows.md +129 -0
- package/skill/assets/activation-rules-default.json +26 -0
- package/skill/assets/multi-skill-settings.json +63 -0
- package/skill/assets/single-skill-settings.json +57 -0
- package/skill/references/invocation-taxonomy.md +2 -2
- package/skill/references/logs.md +164 -2
- package/skill/references/setup-patterns.md +65 -0
- package/skill/references/version-history.md +40 -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
- package/dashboard/index.html +0 -1119
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discover.ts
|
|
3
|
+
*
|
|
4
|
+
* Pure analysis functions for discovering multi-skill workflows from
|
|
5
|
+
* telemetry and usage data. No I/O -- CLI wrapper handles reading JSONL.
|
|
6
|
+
*
|
|
7
|
+
* Adapts patterns from composability-v2.ts but removes single-skill scoping
|
|
8
|
+
* to discover ALL multi-skill workflows across the codebase.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
DiscoveredWorkflow,
|
|
13
|
+
SessionTelemetryRecord,
|
|
14
|
+
SkillUsageRecord,
|
|
15
|
+
WorkflowDiscoveryReport,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
import { clamp } from "../utils/math.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Discover multi-skill workflows from telemetry and usage data.
|
|
21
|
+
*
|
|
22
|
+
* Algorithm:
|
|
23
|
+
* 1. Apply window filter to telemetry (sort by timestamp desc, take N)
|
|
24
|
+
* 2. Build session ID set from filtered telemetry
|
|
25
|
+
* 3. Filter usage records to in-scope sessions
|
|
26
|
+
* 4. Group usage by session_id, sort by timestamp, deduplicate consecutive same-skill
|
|
27
|
+
* 5. Keep sequences with 2+ skills
|
|
28
|
+
* 6. Count frequency of each unique sequence, filter by minOccurrences (default 3)
|
|
29
|
+
* 7. For each qualifying sequence compute metrics
|
|
30
|
+
* 8. If --skill provided, filter to workflows containing that skill
|
|
31
|
+
* 9. Sort by occurrence_count descending
|
|
32
|
+
* 10. Return WorkflowDiscoveryReport
|
|
33
|
+
*/
|
|
34
|
+
export function discoverWorkflows(
|
|
35
|
+
telemetry: SessionTelemetryRecord[],
|
|
36
|
+
usage: SkillUsageRecord[],
|
|
37
|
+
options?: { minOccurrences?: number; window?: number; skill?: string },
|
|
38
|
+
): WorkflowDiscoveryReport {
|
|
39
|
+
const minOccurrences = options?.minOccurrences ?? 3;
|
|
40
|
+
|
|
41
|
+
// 1. Apply window: sort by timestamp descending, take last N
|
|
42
|
+
let sessions = telemetry.filter((r) => r && Array.isArray(r.skills_triggered));
|
|
43
|
+
|
|
44
|
+
if (options?.window && options.window > 0) {
|
|
45
|
+
sessions = sessions
|
|
46
|
+
.sort((a, b) => (b.timestamp ?? "").localeCompare(a.timestamp ?? ""))
|
|
47
|
+
.slice(0, options.window);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. Build a set of session IDs in scope (after windowing)
|
|
51
|
+
const sessionIdSet = new Set(sessions.map((s) => s.session_id));
|
|
52
|
+
|
|
53
|
+
// 3. Filter usage records to in-scope sessions
|
|
54
|
+
const usageInScope = usage.filter((u) => sessionIdSet.has(u.session_id));
|
|
55
|
+
|
|
56
|
+
// 4. Group usage by session_id
|
|
57
|
+
const usageBySession = new Map<string, SkillUsageRecord[]>();
|
|
58
|
+
for (const u of usageInScope) {
|
|
59
|
+
const group = usageBySession.get(u.session_id);
|
|
60
|
+
if (group) {
|
|
61
|
+
group.push(u);
|
|
62
|
+
} else {
|
|
63
|
+
usageBySession.set(u.session_id, [u]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Build ordered sequences per session (ALL sessions, no target skill filter)
|
|
68
|
+
const sessionSequences: Array<{
|
|
69
|
+
skills: string[];
|
|
70
|
+
sessionId: string;
|
|
71
|
+
firstQuery: string;
|
|
72
|
+
}> = [];
|
|
73
|
+
|
|
74
|
+
for (const [sessionId, records] of usageBySession) {
|
|
75
|
+
// Sort by timestamp ascending
|
|
76
|
+
const sorted = [...records].sort((a, b) =>
|
|
77
|
+
(a.timestamp ?? "").localeCompare(b.timestamp ?? ""),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Extract skill names, deduplicate consecutive same-skill entries
|
|
81
|
+
const skills: string[] = [];
|
|
82
|
+
for (const r of sorted) {
|
|
83
|
+
if (skills.length === 0 || skills[skills.length - 1] !== r.skill_name) {
|
|
84
|
+
skills.push(r.skill_name);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 5. Only record sequences with 2+ skills
|
|
89
|
+
if (skills.length >= 2) {
|
|
90
|
+
sessionSequences.push({
|
|
91
|
+
skills,
|
|
92
|
+
sessionId,
|
|
93
|
+
firstQuery: sorted[0]?.query ?? "",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 6. Count frequency of each unique sequence (by JSON key)
|
|
99
|
+
const sequenceCounts = new Map<
|
|
100
|
+
string,
|
|
101
|
+
{ count: number; query: string; skills: string[]; sessionIds: string[] }
|
|
102
|
+
>();
|
|
103
|
+
for (const seq of sessionSequences) {
|
|
104
|
+
const key = JSON.stringify(seq.skills);
|
|
105
|
+
const existing = sequenceCounts.get(key);
|
|
106
|
+
if (existing) {
|
|
107
|
+
existing.count++;
|
|
108
|
+
existing.sessionIds.push(seq.sessionId);
|
|
109
|
+
} else {
|
|
110
|
+
sequenceCounts.set(key, {
|
|
111
|
+
count: 1,
|
|
112
|
+
query: seq.firstQuery,
|
|
113
|
+
skills: seq.skills,
|
|
114
|
+
sessionIds: [seq.sessionId],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Count all orderings of each skill set (for consistency computation)
|
|
120
|
+
const skillSetCounts = new Map<string, number>();
|
|
121
|
+
for (const seq of sessionSequences) {
|
|
122
|
+
const setKey = JSON.stringify([...seq.skills].sort());
|
|
123
|
+
skillSetCounts.set(setKey, (skillSetCounts.get(setKey) ?? 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Build telemetry lookup by session_id
|
|
127
|
+
const telemetryBySession = new Map<string, SessionTelemetryRecord>();
|
|
128
|
+
for (const s of sessions) {
|
|
129
|
+
telemetryBySession.set(s.session_id, s);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Compute per-skill solo error rates (for avg_errors_individual)
|
|
133
|
+
const skillSoloErrors = new Map<string, { totalErrors: number; count: number }>();
|
|
134
|
+
for (const s of sessions) {
|
|
135
|
+
if (s.skills_triggered.length === 1) {
|
|
136
|
+
const skillName = s.skills_triggered[0];
|
|
137
|
+
const entry = skillSoloErrors.get(skillName);
|
|
138
|
+
if (entry) {
|
|
139
|
+
entry.totalErrors += s.errors_encountered ?? 0;
|
|
140
|
+
entry.count++;
|
|
141
|
+
} else {
|
|
142
|
+
skillSoloErrors.set(skillName, {
|
|
143
|
+
totalErrors: s.errors_encountered ?? 0,
|
|
144
|
+
count: 1,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getSkillSoloErrorRate(skillName: string): number | undefined {
|
|
151
|
+
const entry = skillSoloErrors.get(skillName);
|
|
152
|
+
if (!entry || entry.count === 0) return undefined;
|
|
153
|
+
return entry.totalErrors / entry.count;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 7. Build workflows, filtered by minOccurrences
|
|
157
|
+
const workflows: DiscoveredWorkflow[] = [];
|
|
158
|
+
for (const data of sequenceCounts.values()) {
|
|
159
|
+
if (data.count < minOccurrences) continue;
|
|
160
|
+
|
|
161
|
+
// workflow_id = skills.join("->")
|
|
162
|
+
const workflowId = data.skills.join("\u2192");
|
|
163
|
+
|
|
164
|
+
// Get matching telemetry sessions
|
|
165
|
+
const matchingSessions = data.sessionIds
|
|
166
|
+
.map((id) => telemetryBySession.get(id))
|
|
167
|
+
.filter((s): s is SessionTelemetryRecord => s !== undefined);
|
|
168
|
+
|
|
169
|
+
// avg_errors from matching telemetry sessions
|
|
170
|
+
const avgErrors =
|
|
171
|
+
matchingSessions.length > 0
|
|
172
|
+
? matchingSessions.reduce((sum, r) => sum + (r.errors_encountered ?? 0), 0) /
|
|
173
|
+
matchingSessions.length
|
|
174
|
+
: 0;
|
|
175
|
+
|
|
176
|
+
const soloRates = data.skills
|
|
177
|
+
.map((s) => getSkillSoloErrorRate(s))
|
|
178
|
+
.filter((rate): rate is number => rate !== undefined);
|
|
179
|
+
|
|
180
|
+
// avg_errors_individual = max of each skill's solo error rate
|
|
181
|
+
// Note: This differs from composability-v2.ts which uses a single-skill anchor.
|
|
182
|
+
// For multi-skill discovery, we conservatively anchor to the worst solo performer.
|
|
183
|
+
const avgErrorsIndividual = soloRates.length > 0 ? Math.max(...soloRates) : 0;
|
|
184
|
+
|
|
185
|
+
// synergy_score = clamp((individual - together) / (individual + 1), -1, 1)
|
|
186
|
+
// If no solo baseline exists yet, keep the workflow neutral instead of treating missing data as zero.
|
|
187
|
+
const synergyScore =
|
|
188
|
+
soloRates.length > 0
|
|
189
|
+
? clamp((avgErrorsIndividual - avgErrors) / (avgErrorsIndividual + 1), -1, 1)
|
|
190
|
+
: 0;
|
|
191
|
+
|
|
192
|
+
// sequence_consistency = this_order_count / all_orderings_of_same_set
|
|
193
|
+
const setKey = JSON.stringify([...data.skills].sort());
|
|
194
|
+
const totalOrderings = skillSetCounts.get(setKey) ?? data.count;
|
|
195
|
+
const sequenceConsistency = totalOrderings > 0 ? data.count / totalOrderings : 1;
|
|
196
|
+
|
|
197
|
+
// completion_rate = sessions with ALL skills fired / sessions with ANY skill from set
|
|
198
|
+
const skillSet = new Set(data.skills);
|
|
199
|
+
let sessionsWithAny = 0;
|
|
200
|
+
let sessionsWithAll = 0;
|
|
201
|
+
for (const s of sessions) {
|
|
202
|
+
const hasAny = s.skills_triggered.some((sk) => skillSet.has(sk));
|
|
203
|
+
if (hasAny) {
|
|
204
|
+
sessionsWithAny++;
|
|
205
|
+
const hasAll = data.skills.every((sk) => s.skills_triggered.includes(sk));
|
|
206
|
+
if (hasAll) sessionsWithAll++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const completionRate = sessionsWithAny > 0 ? sessionsWithAll / sessionsWithAny : 0;
|
|
210
|
+
|
|
211
|
+
// representative_query = first query from first matching session
|
|
212
|
+
const representativeQuery = data.query;
|
|
213
|
+
|
|
214
|
+
// first_seen / last_seen from matching sessions
|
|
215
|
+
const timestamps = matchingSessions
|
|
216
|
+
.map((s) => s.timestamp)
|
|
217
|
+
.filter((t) => t)
|
|
218
|
+
.sort();
|
|
219
|
+
const firstSeen = timestamps[0] ?? "";
|
|
220
|
+
const lastSeen = timestamps[timestamps.length - 1] ?? "";
|
|
221
|
+
|
|
222
|
+
workflows.push({
|
|
223
|
+
workflow_id: workflowId,
|
|
224
|
+
skills: data.skills,
|
|
225
|
+
occurrence_count: data.count,
|
|
226
|
+
avg_errors: avgErrors,
|
|
227
|
+
avg_errors_individual: avgErrorsIndividual,
|
|
228
|
+
synergy_score: synergyScore,
|
|
229
|
+
representative_query: representativeQuery,
|
|
230
|
+
sequence_consistency: sequenceConsistency,
|
|
231
|
+
completion_rate: completionRate,
|
|
232
|
+
first_seen: firstSeen,
|
|
233
|
+
last_seen: lastSeen,
|
|
234
|
+
session_ids: data.sessionIds,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 8. If --skill provided, filter to workflows containing that skill
|
|
239
|
+
let filtered = workflows;
|
|
240
|
+
if (options?.skill) {
|
|
241
|
+
const skillFilter = options.skill;
|
|
242
|
+
filtered = workflows.filter((w) => w.skills.includes(skillFilter));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 9. Sort by occurrence_count descending
|
|
246
|
+
filtered.sort((a, b) => b.occurrence_count - a.occurrence_count);
|
|
247
|
+
|
|
248
|
+
// 10. Return WorkflowDiscoveryReport
|
|
249
|
+
return {
|
|
250
|
+
workflows: filtered,
|
|
251
|
+
total_sessions_analyzed: sessions.length,
|
|
252
|
+
generated_at: new Date().toISOString(),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-md-writer.ts
|
|
3
|
+
*
|
|
4
|
+
* Line-based parser and writer for the `## Workflows` section in SKILL.md files.
|
|
5
|
+
* Pure functions, zero dependencies — follows the frontmatter.ts pattern.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CodifiedWorkflow } from "../types.js";
|
|
9
|
+
|
|
10
|
+
type WorkflowBuilder = Pick<CodifiedWorkflow, "name" | "skills" | "source"> &
|
|
11
|
+
Partial<Pick<CodifiedWorkflow, "description" | "discovered_from">>;
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Parser
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse existing `## Workflows` section from SKILL.md content.
|
|
19
|
+
* Returns an empty array if the section is missing or empty.
|
|
20
|
+
*/
|
|
21
|
+
export function parseWorkflowsSection(content: string): CodifiedWorkflow[] {
|
|
22
|
+
const lines = content.split("\n");
|
|
23
|
+
const workflows: CodifiedWorkflow[] = [];
|
|
24
|
+
|
|
25
|
+
// Find the ## Workflows heading
|
|
26
|
+
let sectionStart = -1;
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
if (lines[i].trim() === "## Workflows") {
|
|
29
|
+
sectionStart = i + 1;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (sectionStart < 0) return [];
|
|
35
|
+
|
|
36
|
+
// Find the end of the section (next ## heading or EOF)
|
|
37
|
+
let sectionEnd = lines.length;
|
|
38
|
+
for (let i = sectionStart; i < lines.length; i++) {
|
|
39
|
+
if (/^## /.test(lines[i]) && lines[i].trim() !== "## Workflows") {
|
|
40
|
+
sectionEnd = i;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse each ### subsection within the workflows section
|
|
46
|
+
const sectionLines = lines.slice(sectionStart, sectionEnd);
|
|
47
|
+
let current: WorkflowBuilder | null = null;
|
|
48
|
+
|
|
49
|
+
for (const line of sectionLines) {
|
|
50
|
+
const trimmed = line.trim();
|
|
51
|
+
|
|
52
|
+
if (trimmed.startsWith("### ")) {
|
|
53
|
+
// Save previous workflow if any
|
|
54
|
+
if (current) workflows.push(current);
|
|
55
|
+
current = {
|
|
56
|
+
name: trimmed.slice(4).trim(),
|
|
57
|
+
skills: [],
|
|
58
|
+
source: "authored",
|
|
59
|
+
};
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!current) continue;
|
|
64
|
+
|
|
65
|
+
if (trimmed.startsWith("- **Skills:**")) {
|
|
66
|
+
const skillsStr = trimmed.slice("- **Skills:**".length).trim();
|
|
67
|
+
current.skills = skillsStr
|
|
68
|
+
.split(" \u2192 ")
|
|
69
|
+
.map((s) => s.trim())
|
|
70
|
+
.filter(Boolean);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (trimmed.startsWith("- **Trigger:**")) {
|
|
75
|
+
current.description = trimmed.slice("- **Trigger:**".length).trim();
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (trimmed.startsWith("- **Source:**")) {
|
|
80
|
+
const sourceStr = trimmed.slice("- **Source:**".length).trim();
|
|
81
|
+
const discoveredMatch = sourceStr.match(
|
|
82
|
+
/^Discovered from (\d+) sessions? \(synergy: (-?\d+(?:\.\d+)?)\)$/,
|
|
83
|
+
);
|
|
84
|
+
if (discoveredMatch) {
|
|
85
|
+
current.source = "discovered";
|
|
86
|
+
current.discovered_from = {
|
|
87
|
+
workflow_id: current.skills.join("\u2192"),
|
|
88
|
+
occurrence_count: parseInt(discoveredMatch[1], 10),
|
|
89
|
+
synergy_score: parseFloat(discoveredMatch[2]),
|
|
90
|
+
};
|
|
91
|
+
} else {
|
|
92
|
+
current.source = "authored";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Don't forget the last workflow
|
|
98
|
+
if (current) workflows.push(current);
|
|
99
|
+
|
|
100
|
+
return workflows;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Writer — append
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format a single workflow as a markdown subsection.
|
|
109
|
+
*/
|
|
110
|
+
function formatWorkflowSubsection(workflow: CodifiedWorkflow): string {
|
|
111
|
+
const lines: string[] = [];
|
|
112
|
+
lines.push(`### ${workflow.name}`);
|
|
113
|
+
lines.push(`- **Skills:** ${workflow.skills.join(" \u2192 ")}`);
|
|
114
|
+
if (workflow.description) {
|
|
115
|
+
lines.push(`- **Trigger:** ${workflow.description}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (workflow.source === "discovered" && workflow.discovered_from) {
|
|
119
|
+
const { occurrence_count, synergy_score } = workflow.discovered_from;
|
|
120
|
+
lines.push(
|
|
121
|
+
`- **Source:** Discovered from ${occurrence_count} sessions (synergy: ${synergy_score.toFixed(2)})`,
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
lines.push(`- **Source:** authored`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return lines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Append a workflow to the `## Workflows` section.
|
|
132
|
+
* Creates the section if it doesn't exist.
|
|
133
|
+
* Returns content unchanged if a workflow with the same name already exists.
|
|
134
|
+
*/
|
|
135
|
+
export function appendWorkflow(content: string, workflow: CodifiedWorkflow): string {
|
|
136
|
+
// Check for duplicate
|
|
137
|
+
const existing = parseWorkflowsSection(content);
|
|
138
|
+
if (existing.some((w) => w.name === workflow.name)) {
|
|
139
|
+
return content;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const subsection = formatWorkflowSubsection(workflow);
|
|
143
|
+
const lines = content.split("\n");
|
|
144
|
+
|
|
145
|
+
// Find the ## Workflows heading
|
|
146
|
+
let sectionStart = -1;
|
|
147
|
+
for (let i = 0; i < lines.length; i++) {
|
|
148
|
+
if (lines[i].trim() === "## Workflows") {
|
|
149
|
+
sectionStart = i;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (sectionStart >= 0) {
|
|
155
|
+
// Find the end of the workflows section (next ## heading or EOF)
|
|
156
|
+
let sectionEnd = lines.length;
|
|
157
|
+
for (let i = sectionStart + 1; i < lines.length; i++) {
|
|
158
|
+
if (/^## /.test(lines[i])) {
|
|
159
|
+
sectionEnd = i;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Insert before the next ## heading (or at EOF)
|
|
165
|
+
const before = lines.slice(0, sectionEnd);
|
|
166
|
+
const after = lines.slice(sectionEnd);
|
|
167
|
+
|
|
168
|
+
// Ensure blank line before the new subsection
|
|
169
|
+
const lastNonEmpty = findLastNonEmptyIndex(before);
|
|
170
|
+
const needsBlankLine = lastNonEmpty >= 0 && lastNonEmpty === before.length - 1;
|
|
171
|
+
|
|
172
|
+
const result: string[] = [...before];
|
|
173
|
+
if (needsBlankLine) result.push("");
|
|
174
|
+
result.push(subsection);
|
|
175
|
+
if (after.length > 0) {
|
|
176
|
+
result.push("");
|
|
177
|
+
result.push(...after);
|
|
178
|
+
}
|
|
179
|
+
return result.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// No ## Workflows section — append at end
|
|
183
|
+
const trimmedContent = content.replace(/\n*$/, "");
|
|
184
|
+
return `${trimmedContent}\n\n## Workflows\n\n${subsection}\n`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Writer — remove
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Remove a workflow by name from the `## Workflows` section.
|
|
193
|
+
* If the section becomes empty after removal, the section heading is also removed.
|
|
194
|
+
* Returns content unchanged if the workflow is not found.
|
|
195
|
+
*/
|
|
196
|
+
export function removeWorkflow(content: string, name: string): string {
|
|
197
|
+
const lines = content.split("\n");
|
|
198
|
+
|
|
199
|
+
// Find the ## Workflows heading
|
|
200
|
+
let sectionStart = -1;
|
|
201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
202
|
+
if (lines[i].trim() === "## Workflows") {
|
|
203
|
+
sectionStart = i;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (sectionStart < 0) return content;
|
|
209
|
+
|
|
210
|
+
// Find the end of the workflows section
|
|
211
|
+
let sectionEnd = lines.length;
|
|
212
|
+
for (let i = sectionStart + 1; i < lines.length; i++) {
|
|
213
|
+
if (/^## /.test(lines[i])) {
|
|
214
|
+
sectionEnd = i;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Find the ### <name> subsection
|
|
220
|
+
let subStart = -1;
|
|
221
|
+
let subEnd = -1;
|
|
222
|
+
|
|
223
|
+
for (let i = sectionStart + 1; i < sectionEnd; i++) {
|
|
224
|
+
if (lines[i].trim() === `### ${name}`) {
|
|
225
|
+
subStart = i;
|
|
226
|
+
// Find the end of this subsection (next ### or ## or sectionEnd)
|
|
227
|
+
subEnd = sectionEnd;
|
|
228
|
+
for (let j = i + 1; j < sectionEnd; j++) {
|
|
229
|
+
if (/^### /.test(lines[j])) {
|
|
230
|
+
subEnd = j;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (subStart < 0) return content;
|
|
239
|
+
|
|
240
|
+
// Remove blank lines before the subsection (cleanup)
|
|
241
|
+
let removeFrom = subStart;
|
|
242
|
+
while (removeFrom > sectionStart + 1 && lines[removeFrom - 1].trim() === "") {
|
|
243
|
+
removeFrom--;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Remove blank lines after the subsection (cleanup)
|
|
247
|
+
let removeTo = subEnd;
|
|
248
|
+
while (removeTo < sectionEnd && lines[removeTo]?.trim() === "") {
|
|
249
|
+
removeTo++;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Build result without the removed subsection
|
|
253
|
+
const before = lines.slice(0, removeFrom);
|
|
254
|
+
const after = lines.slice(removeTo);
|
|
255
|
+
|
|
256
|
+
// Check if the workflows section is now empty
|
|
257
|
+
const remaining = [...before.slice(sectionStart + 1), ...after.slice(0, sectionEnd - removeTo)];
|
|
258
|
+
const hasRemainingWorkflows = remaining.some((l) => /^### /.test(l));
|
|
259
|
+
|
|
260
|
+
if (!hasRemainingWorkflows) {
|
|
261
|
+
// Remove the entire ## Workflows section (heading + any blank lines)
|
|
262
|
+
let headingStart = sectionStart;
|
|
263
|
+
// Remove blank lines before the heading too
|
|
264
|
+
while (headingStart > 0 && lines[headingStart - 1].trim() === "") {
|
|
265
|
+
headingStart--;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const beforeSection = lines.slice(0, headingStart);
|
|
269
|
+
const afterSection = lines.slice(removeTo);
|
|
270
|
+
|
|
271
|
+
const result = [...beforeSection, ...afterSection].join("\n");
|
|
272
|
+
// Clean up trailing newlines
|
|
273
|
+
return result.replace(/\n{3,}$/, "\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return [...before, ...after].join("\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Helpers
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
function findLastNonEmptyIndex(lines: string[]): number {
|
|
284
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
285
|
+
if (lines[i].trim() !== "") return i;
|
|
286
|
+
}
|
|
287
|
+
return -1;
|
|
288
|
+
}
|