selftune 0.2.0 → 0.2.2
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 +20 -10
- package/.claude/agents/evolution-reviewer.md +14 -1
- package/.claude/agents/integration-guide.md +18 -6
- package/.claude/agents/pattern-analyst.md +18 -5
- package/CHANGELOG.md +12 -4
- package/README.md +43 -35
- 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/cli/selftune/badge/badge-data.ts +1 -1
- package/cli/selftune/badge/badge.ts +4 -8
- package/cli/selftune/canonical-export.ts +183 -0
- package/cli/selftune/constants.ts +28 -0
- package/cli/selftune/contribute/contribute.ts +1 -1
- package/cli/selftune/cron/setup.ts +17 -17
- package/cli/selftune/dashboard-contract.ts +202 -0
- package/cli/selftune/dashboard-server.ts +653 -186
- package/cli/selftune/dashboard.ts +41 -176
- package/cli/selftune/eval/baseline.ts +5 -4
- package/cli/selftune/eval/composability-v2.ts +273 -0
- package/cli/selftune/eval/hooks-to-evals.ts +34 -15
- package/cli/selftune/eval/unit-test-cli.ts +1 -1
- package/cli/selftune/evolution/evidence.ts +26 -0
- package/cli/selftune/evolution/evolve-body.ts +105 -11
- package/cli/selftune/evolution/evolve.ts +371 -25
- package/cli/selftune/evolution/extract-patterns.ts +87 -29
- package/cli/selftune/evolution/rollback.ts +2 -2
- package/cli/selftune/grading/auto-grade.ts +200 -0
- package/cli/selftune/grading/grade-session.ts +448 -97
- package/cli/selftune/grading/results.ts +42 -0
- package/cli/selftune/hooks/prompt-log.ts +172 -2
- package/cli/selftune/hooks/session-stop.ts +123 -3
- package/cli/selftune/hooks/skill-eval.ts +119 -3
- package/cli/selftune/index.ts +395 -116
- package/cli/selftune/ingestors/claude-replay.ts +140 -114
- 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 +141 -8
- package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
- package/cli/selftune/init.ts +227 -14
- 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/monitoring/watch.ts +66 -15
- 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 +48 -26
- package/cli/selftune/sync.ts +627 -0
- package/cli/selftune/types.ts +148 -0
- package/cli/selftune/utils/canonical-log.ts +45 -0
- package/cli/selftune/utils/hooks.ts +41 -0
- package/cli/selftune/utils/html.ts +27 -0
- package/cli/selftune/utils/llm-call.ts +78 -20
- 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 +272 -26
- 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 +21 -8
- 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 +84 -53
- package/skill/Workflows/AutoActivation.md +17 -16
- package/skill/Workflows/Badge.md +6 -0
- package/skill/Workflows/Baseline.md +46 -23
- package/skill/Workflows/Composability.md +12 -5
- package/skill/Workflows/Contribute.md +17 -14
- package/skill/Workflows/Cron.md +56 -79
- package/skill/Workflows/Dashboard.md +45 -34
- package/skill/Workflows/Doctor.md +30 -17
- package/skill/Workflows/Evals.md +64 -40
- package/skill/Workflows/EvolutionMemory.md +2 -0
- package/skill/Workflows/Evolve.md +102 -47
- package/skill/Workflows/EvolveBody.md +6 -6
- package/skill/Workflows/Grade.md +36 -31
- package/skill/Workflows/ImportSkillsBench.md +11 -5
- package/skill/Workflows/Ingest.md +43 -36
- package/skill/Workflows/Initialize.md +44 -30
- package/skill/Workflows/Orchestrate.md +139 -0
- package/skill/Workflows/Replay.md +39 -18
- package/skill/Workflows/Rollback.md +3 -3
- package/skill/Workflows/Schedule.md +61 -0
- package/skill/Workflows/Sync.md +88 -0
- package/skill/Workflows/UnitTest.md +34 -22
- package/skill/Workflows/Watch.md +14 -4
- 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 +1 -1
- package/templates/multi-skill-settings.json +7 -7
- package/templates/single-skill-settings.json +6 -6
- package/dashboard/index.html +0 -1680
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflows.ts
|
|
3
|
+
*
|
|
4
|
+
* CLI entry point and formatter for multi-skill workflow discovery and management.
|
|
5
|
+
*
|
|
6
|
+
* Exports:
|
|
7
|
+
* - formatWorkflows() (pure formatter, deterministic)
|
|
8
|
+
* - cliMain() (reads logs, discovers workflows, prints output or saves)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { parseArgs } from "node:util";
|
|
13
|
+
import { TELEMETRY_LOG } from "../constants.js";
|
|
14
|
+
import type {
|
|
15
|
+
CodifiedWorkflow,
|
|
16
|
+
SessionTelemetryRecord,
|
|
17
|
+
WorkflowDiscoveryReport,
|
|
18
|
+
} from "../types.js";
|
|
19
|
+
import { readJsonl } from "../utils/jsonl.js";
|
|
20
|
+
import { readEffectiveSkillUsageRecords } from "../utils/skill-log.js";
|
|
21
|
+
import { discoverWorkflows } from "./discover.js";
|
|
22
|
+
import { appendWorkflow } from "./skill-md-writer.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// formatWorkflows — pure formatter
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export function formatWorkflows(report: WorkflowDiscoveryReport): string {
|
|
29
|
+
if (report.workflows.length === 0) {
|
|
30
|
+
return "No workflows discovered.";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
lines.push(`Discovered Workflows (from ${report.total_sessions_analyzed} sessions):`);
|
|
35
|
+
lines.push("");
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < report.workflows.length; i++) {
|
|
38
|
+
const wf = report.workflows[i];
|
|
39
|
+
const chain = wf.skills.join(" \u2192 ");
|
|
40
|
+
const synergy = wf.synergy_score.toFixed(2);
|
|
41
|
+
const consistency = Math.round(wf.sequence_consistency * 100);
|
|
42
|
+
const completion = Math.round(wf.completion_rate * 100);
|
|
43
|
+
|
|
44
|
+
lines.push(` ${i + 1}. ${chain}`);
|
|
45
|
+
lines.push(
|
|
46
|
+
` Occurrences: ${wf.occurrence_count} | Synergy: ${synergy} | Consistency: ${consistency}% | Completion: ${completion}%`,
|
|
47
|
+
);
|
|
48
|
+
if (wf.representative_query) {
|
|
49
|
+
lines.push(` Common trigger: "${wf.representative_query}"`);
|
|
50
|
+
}
|
|
51
|
+
if (i < report.workflows.length - 1) {
|
|
52
|
+
lines.push("");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// cliMain — reads logs, discovers workflows, prints or saves
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export async function cliMain(): Promise<void> {
|
|
64
|
+
const { values, positionals } = parseArgs({
|
|
65
|
+
options: {
|
|
66
|
+
"min-occurrences": { type: "string" },
|
|
67
|
+
window: { type: "string" },
|
|
68
|
+
skill: { type: "string" },
|
|
69
|
+
"skill-path": { type: "string" },
|
|
70
|
+
json: { type: "boolean" },
|
|
71
|
+
},
|
|
72
|
+
strict: true,
|
|
73
|
+
allowPositionals: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const subcommand = positionals[0];
|
|
77
|
+
const minOccurrences = values["min-occurrences"]
|
|
78
|
+
? Number.parseInt(values["min-occurrences"], 10)
|
|
79
|
+
: undefined;
|
|
80
|
+
if (minOccurrences !== undefined && (Number.isNaN(minOccurrences) || minOccurrences < 0)) {
|
|
81
|
+
console.error("[ERROR] --min-occurrences must be a non-negative integer.");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const window = values.window ? Number.parseInt(values.window, 10) : undefined;
|
|
85
|
+
if (window !== undefined && (Number.isNaN(window) || window < 0)) {
|
|
86
|
+
console.error("[ERROR] --window must be a non-negative integer.");
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Read telemetry and skill usage logs
|
|
91
|
+
const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
|
|
92
|
+
const usage = readEffectiveSkillUsageRecords();
|
|
93
|
+
|
|
94
|
+
// Discover workflows
|
|
95
|
+
const report = discoverWorkflows(telemetry, usage, {
|
|
96
|
+
minOccurrences,
|
|
97
|
+
window,
|
|
98
|
+
skill: values.skill,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (subcommand === "save") {
|
|
102
|
+
// Save subcommand: find workflow, append to SKILL.md
|
|
103
|
+
const nameArg = positionals[1];
|
|
104
|
+
if (!nameArg) {
|
|
105
|
+
console.error("[ERROR] Usage: selftune workflows save <name-or-index>");
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Match by numeric index (1-based) or workflow_id
|
|
110
|
+
let workflow = report.workflows.find((w) => w.workflow_id === nameArg);
|
|
111
|
+
if (!workflow) {
|
|
112
|
+
const idx = Number.parseInt(nameArg, 10);
|
|
113
|
+
if (!Number.isNaN(idx) && idx >= 1 && idx <= report.workflows.length) {
|
|
114
|
+
workflow = report.workflows[idx - 1];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!workflow) {
|
|
119
|
+
console.error(`[ERROR] No workflow found matching "${nameArg}".`);
|
|
120
|
+
console.error("Run 'selftune workflows' to see discovered workflows and their indices.");
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Determine SKILL.md path
|
|
125
|
+
let skillPath = values["skill-path"];
|
|
126
|
+
if (!skillPath) {
|
|
127
|
+
// Filter usage records to only sessions that contributed to this workflow
|
|
128
|
+
const sessionSet = new Set(workflow.session_ids);
|
|
129
|
+
const firstSkill = workflow.skills[0];
|
|
130
|
+
const matchingRecords = usage.filter(
|
|
131
|
+
(u) => u.skill_name === firstSkill && sessionSet.has(u.session_id),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Collect unique skill_paths from matching records
|
|
135
|
+
const uniquePaths = [...new Set(matchingRecords.map((r) => r.skill_path))];
|
|
136
|
+
|
|
137
|
+
if (uniquePaths.length === 1) {
|
|
138
|
+
skillPath = uniquePaths[0];
|
|
139
|
+
} else if (uniquePaths.length > 1) {
|
|
140
|
+
// Ambiguous: multiple SKILL.md paths found across contributing sessions
|
|
141
|
+
console.error(`[ERROR] Multiple SKILL.md paths found for "${firstSkill}":`);
|
|
142
|
+
for (const p of uniquePaths) {
|
|
143
|
+
console.error(` - ${p}`);
|
|
144
|
+
}
|
|
145
|
+
console.error("Use --skill-path to specify which one to update.");
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!skillPath || !existsSync(skillPath)) {
|
|
151
|
+
console.error(`[ERROR] Could not determine SKILL.md path. Use --skill-path to specify.`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build CodifiedWorkflow
|
|
156
|
+
const codified: CodifiedWorkflow = {
|
|
157
|
+
name: workflow.skills.join("-"),
|
|
158
|
+
skills: workflow.skills,
|
|
159
|
+
description: workflow.representative_query || undefined,
|
|
160
|
+
source: "discovered",
|
|
161
|
+
discovered_from: {
|
|
162
|
+
workflow_id: workflow.workflow_id,
|
|
163
|
+
occurrence_count: workflow.occurrence_count,
|
|
164
|
+
synergy_score: workflow.synergy_score,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Read, append, write
|
|
169
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
170
|
+
const updated = appendWorkflow(content, codified);
|
|
171
|
+
|
|
172
|
+
if (updated === content) {
|
|
173
|
+
console.log(`Workflow "${codified.name}" already exists in ${skillPath}`);
|
|
174
|
+
} else {
|
|
175
|
+
writeFileSync(skillPath, updated, "utf-8");
|
|
176
|
+
console.log(`Saved workflow "${codified.name}" to ${skillPath}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Default: discover and display
|
|
183
|
+
if (values.json || !process.stdout.isTTY) {
|
|
184
|
+
console.log(JSON.stringify(report, null, 2));
|
|
185
|
+
} else {
|
|
186
|
+
console.log(formatWorkflows(report));
|
|
187
|
+
}
|
|
188
|
+
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "selftune",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Self-improving skills CLI for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Daniel Petro",
|
|
8
|
-
"homepage": "https://github.com/
|
|
8
|
+
"homepage": "https://github.com/selftune-dev/selftune#readme",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/
|
|
11
|
+
"url": "git+https://github.com/selftune-dev/selftune.git"
|
|
12
12
|
},
|
|
13
13
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/
|
|
14
|
+
"url": "https://github.com/selftune-dev/selftune/issues"
|
|
15
15
|
},
|
|
16
16
|
"funding": {
|
|
17
17
|
"type": "github",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"assets/",
|
|
41
41
|
"bin/",
|
|
42
42
|
"cli/selftune/",
|
|
43
|
-
"dashboard/",
|
|
43
|
+
"apps/local-dashboard/dist/",
|
|
44
|
+
"packages/telemetry-contract/",
|
|
44
45
|
"templates/",
|
|
45
46
|
".claude/agents/",
|
|
46
47
|
"skill/",
|
|
@@ -48,15 +49,27 @@
|
|
|
48
49
|
"CHANGELOG.md"
|
|
49
50
|
],
|
|
50
51
|
"scripts": {
|
|
51
|
-
"
|
|
52
|
-
"
|
|
52
|
+
"dev": "sh -c 'if lsof -iTCP:7888 -sTCP:LISTEN >/dev/null 2>&1; then if curl -fsS http://127.0.0.1:7888/api/health | grep -q selftune-dashboard; then echo \"Using existing dashboard server on 7888\"; cd apps/local-dashboard && bun install && bunx vite --strictPort; else echo \"Port 7888 is occupied by a non-selftune service\"; exit 1; fi; else cd apps/local-dashboard && bun install && bun run dev; fi'",
|
|
53
|
+
"dev:dashboard": "bun run cli/selftune/index.ts dashboard --port 7888 --no-open",
|
|
54
|
+
"lint": "bunx @biomejs/biome check .",
|
|
55
|
+
"lint:fix": "bunx @biomejs/biome check --write .",
|
|
53
56
|
"lint:arch": "bun run lint-architecture.ts",
|
|
54
57
|
"test": "bun test",
|
|
58
|
+
"test:fast": "bun test $(find tests -name '*.test.ts' ! -name 'evolve.test.ts' ! -name 'integration.test.ts' ! -name 'dashboard-server.test.ts' ! -path '*/blog-proof/*')",
|
|
59
|
+
"test:slow": "bun test tests/evolution/evolve.test.ts tests/evolution/integration.test.ts tests/monitoring/integration.test.ts tests/dashboard/dashboard-server.test.ts",
|
|
60
|
+
"build:dashboard": "cd apps/local-dashboard && bun install && bunx vite build",
|
|
61
|
+
"prepublishOnly": "bun run build:dashboard",
|
|
55
62
|
"check": "bun run lint && bun run lint:arch && bun test",
|
|
56
63
|
"start": "bun run cli/selftune/index.ts --help"
|
|
57
64
|
},
|
|
65
|
+
"workspaces": [
|
|
66
|
+
"packages/*"
|
|
67
|
+
],
|
|
68
|
+
"dependencies": {
|
|
69
|
+
"@selftune/telemetry-contract": "workspace:*"
|
|
70
|
+
},
|
|
58
71
|
"devDependencies": {
|
|
59
|
-
"@biomejs/biome": "
|
|
72
|
+
"@biomejs/biome": "2.4.7",
|
|
60
73
|
"@types/bun": "^1.1.0"
|
|
61
74
|
}
|
|
62
75
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @selftune/telemetry-contract
|
|
2
|
+
|
|
3
|
+
Canonical telemetry contract shared between local CLI normalization and cloud ingestion.
|
|
4
|
+
|
|
5
|
+
This package is intentionally small:
|
|
6
|
+
|
|
7
|
+
- canonical enums
|
|
8
|
+
- canonical record types
|
|
9
|
+
- lightweight runtime guards
|
|
10
|
+
|
|
11
|
+
It does not include adapter logic, projections, or product-specific analytics.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"_description": "Minimal valid session record",
|
|
4
|
+
"record_kind": "session",
|
|
5
|
+
"schema_version": "2.0",
|
|
6
|
+
"normalizer_version": "0.2.1",
|
|
7
|
+
"normalized_at": "2026-01-15T12:00:00Z",
|
|
8
|
+
"platform": "claude_code",
|
|
9
|
+
"capture_mode": "hook",
|
|
10
|
+
"raw_source_ref": { "path": "/tmp/raw/session-001.jsonl" },
|
|
11
|
+
"source_session_kind": "interactive",
|
|
12
|
+
"session_id": "golden-session-001",
|
|
13
|
+
"started_at": "2026-01-15T11:50:00Z",
|
|
14
|
+
"ended_at": "2026-01-15T12:05:00Z",
|
|
15
|
+
"completion_status": "completed"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"_description": "Minimal valid prompt record",
|
|
19
|
+
"record_kind": "prompt",
|
|
20
|
+
"schema_version": "2.0",
|
|
21
|
+
"normalizer_version": "0.2.1",
|
|
22
|
+
"normalized_at": "2026-01-15T12:00:00Z",
|
|
23
|
+
"platform": "claude_code",
|
|
24
|
+
"capture_mode": "hook",
|
|
25
|
+
"raw_source_ref": { "path": "/tmp/raw/session-001.jsonl", "line": 3 },
|
|
26
|
+
"source_session_kind": "interactive",
|
|
27
|
+
"session_id": "golden-session-001",
|
|
28
|
+
"prompt_id": "golden-prompt-001",
|
|
29
|
+
"occurred_at": "2026-01-15T11:51:00Z",
|
|
30
|
+
"prompt_text": "Fix the login bug",
|
|
31
|
+
"prompt_kind": "user",
|
|
32
|
+
"is_actionable": true
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"_description": "Minimal valid skill_invocation record",
|
|
36
|
+
"record_kind": "skill_invocation",
|
|
37
|
+
"schema_version": "2.0",
|
|
38
|
+
"normalizer_version": "0.2.1",
|
|
39
|
+
"normalized_at": "2026-01-15T12:00:00Z",
|
|
40
|
+
"platform": "claude_code",
|
|
41
|
+
"capture_mode": "hook",
|
|
42
|
+
"raw_source_ref": { "path": "/tmp/raw/session-001.jsonl", "line": 7 },
|
|
43
|
+
"source_session_kind": "interactive",
|
|
44
|
+
"session_id": "golden-session-001",
|
|
45
|
+
"skill_invocation_id": "golden-invocation-001",
|
|
46
|
+
"occurred_at": "2026-01-15T11:52:00Z",
|
|
47
|
+
"matched_prompt_id": "golden-prompt-001",
|
|
48
|
+
"skill_name": "commit",
|
|
49
|
+
"invocation_mode": "explicit",
|
|
50
|
+
"triggered": true,
|
|
51
|
+
"confidence": 1.0
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"_description": "Minimal valid execution_fact record",
|
|
55
|
+
"record_kind": "execution_fact",
|
|
56
|
+
"schema_version": "2.0",
|
|
57
|
+
"normalizer_version": "0.2.1",
|
|
58
|
+
"normalized_at": "2026-01-15T12:00:00Z",
|
|
59
|
+
"platform": "claude_code",
|
|
60
|
+
"capture_mode": "hook",
|
|
61
|
+
"raw_source_ref": { "path": "/tmp/raw/session-001.jsonl", "line": 15 },
|
|
62
|
+
"source_session_kind": "interactive",
|
|
63
|
+
"session_id": "golden-session-001",
|
|
64
|
+
"occurred_at": "2026-01-15T12:04:00Z",
|
|
65
|
+
"tool_calls_json": { "Read": 5, "Edit": 3, "Bash": 2 },
|
|
66
|
+
"total_tool_calls": 10,
|
|
67
|
+
"bash_commands_redacted": ["git status", "bun test"],
|
|
68
|
+
"assistant_turns": 4,
|
|
69
|
+
"errors_encountered": 0,
|
|
70
|
+
"completion_status": "completed"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"_description": "Minimal valid normalization_run record",
|
|
74
|
+
"record_kind": "normalization_run",
|
|
75
|
+
"schema_version": "2.0",
|
|
76
|
+
"normalizer_version": "0.2.1",
|
|
77
|
+
"normalized_at": "2026-01-15T12:00:00Z",
|
|
78
|
+
"platform": "claude_code",
|
|
79
|
+
"capture_mode": "hook",
|
|
80
|
+
"raw_source_ref": {},
|
|
81
|
+
"run_id": "golden-run-001",
|
|
82
|
+
"run_at": "2026-01-15T12:00:00Z",
|
|
83
|
+
"raw_records_seen": 42,
|
|
84
|
+
"canonical_records_written": 38,
|
|
85
|
+
"repair_applied": false
|
|
86
|
+
}
|
|
87
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CANONICAL_SCHEMA_VERSION } from "../src/types.js";
|
|
5
|
+
import { isCanonicalRecord } from "../src/validators.js";
|
|
6
|
+
|
|
7
|
+
const fixtures = JSON.parse(
|
|
8
|
+
readFileSync(join(import.meta.dirname, "golden.json"), "utf-8"),
|
|
9
|
+
) as Record<string, unknown>[];
|
|
10
|
+
|
|
11
|
+
describe("golden fixtures", () => {
|
|
12
|
+
test("all fixtures pass isCanonicalRecord", () => {
|
|
13
|
+
for (const fixture of fixtures) {
|
|
14
|
+
const desc = fixture._description ?? fixture.record_kind;
|
|
15
|
+
const result = isCanonicalRecord(fixture);
|
|
16
|
+
if (!result) throw new Error(`Failed: ${desc}`);
|
|
17
|
+
expect(result).toBe(true);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("all fixtures use current schema version", () => {
|
|
22
|
+
for (const fixture of fixtures) {
|
|
23
|
+
expect(fixture.schema_version).toBe(CANONICAL_SCHEMA_VERSION);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("covers every record_kind", () => {
|
|
28
|
+
const kinds = new Set(fixtures.map((f) => f.record_kind));
|
|
29
|
+
expect(kinds).toContain("session");
|
|
30
|
+
expect(kinds).toContain("prompt");
|
|
31
|
+
expect(kinds).toContain("skill_invocation");
|
|
32
|
+
expect(kinds).toContain("execution_fact");
|
|
33
|
+
expect(kinds).toContain("normalization_run");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("mutated fixtures fail validation", () => {
|
|
37
|
+
for (const fixture of fixtures) {
|
|
38
|
+
const bad = { ...fixture, schema_version: "0.0" };
|
|
39
|
+
expect(isCanonicalRecord(bad)).toBe(false);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/index.js";
|