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,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
+ }