selftune 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.claude/agents/diagnosis-analyst.md +146 -0
  2. package/.claude/agents/evolution-reviewer.md +167 -0
  3. package/.claude/agents/integration-guide.md +200 -0
  4. package/.claude/agents/pattern-analyst.md +147 -0
  5. package/CHANGELOG.md +37 -0
  6. package/README.md +96 -256
  7. package/assets/BeforeAfter.gif +0 -0
  8. package/assets/FeedbackLoop.gif +0 -0
  9. package/assets/logo.svg +9 -0
  10. package/assets/skill-health-badge.svg +20 -0
  11. package/cli/selftune/activation-rules.ts +171 -0
  12. package/cli/selftune/badge/badge-data.ts +108 -0
  13. package/cli/selftune/badge/badge-svg.ts +212 -0
  14. package/cli/selftune/badge/badge.ts +103 -0
  15. package/cli/selftune/constants.ts +75 -1
  16. package/cli/selftune/contribute/bundle.ts +314 -0
  17. package/cli/selftune/contribute/contribute.ts +214 -0
  18. package/cli/selftune/contribute/sanitize.ts +162 -0
  19. package/cli/selftune/cron/setup.ts +266 -0
  20. package/cli/selftune/dashboard-server.ts +582 -0
  21. package/cli/selftune/dashboard.ts +25 -3
  22. package/cli/selftune/eval/baseline.ts +247 -0
  23. package/cli/selftune/eval/composability.ts +117 -0
  24. package/cli/selftune/eval/generate-unit-tests.ts +143 -0
  25. package/cli/selftune/eval/hooks-to-evals.ts +68 -2
  26. package/cli/selftune/eval/import-skillsbench.ts +221 -0
  27. package/cli/selftune/eval/synthetic-evals.ts +172 -0
  28. package/cli/selftune/eval/unit-test-cli.ts +152 -0
  29. package/cli/selftune/eval/unit-test.ts +196 -0
  30. package/cli/selftune/evolution/deploy-proposal.ts +142 -1
  31. package/cli/selftune/evolution/evolve-body.ts +492 -0
  32. package/cli/selftune/evolution/evolve.ts +466 -103
  33. package/cli/selftune/evolution/extract-patterns.ts +32 -1
  34. package/cli/selftune/evolution/pareto.ts +314 -0
  35. package/cli/selftune/evolution/propose-body.ts +171 -0
  36. package/cli/selftune/evolution/propose-description.ts +100 -2
  37. package/cli/selftune/evolution/propose-routing.ts +166 -0
  38. package/cli/selftune/evolution/refine-body.ts +141 -0
  39. package/cli/selftune/evolution/rollback.ts +19 -2
  40. package/cli/selftune/evolution/validate-body.ts +254 -0
  41. package/cli/selftune/evolution/validate-proposal.ts +257 -35
  42. package/cli/selftune/evolution/validate-routing.ts +177 -0
  43. package/cli/selftune/grading/grade-session.ts +138 -18
  44. package/cli/selftune/grading/pre-gates.ts +104 -0
  45. package/cli/selftune/hooks/auto-activate.ts +185 -0
  46. package/cli/selftune/hooks/evolution-guard.ts +165 -0
  47. package/cli/selftune/hooks/skill-change-guard.ts +112 -0
  48. package/cli/selftune/index.ts +88 -0
  49. package/cli/selftune/ingestors/claude-replay.ts +351 -0
  50. package/cli/selftune/ingestors/openclaw-ingest.ts +440 -0
  51. package/cli/selftune/init.ts +150 -3
  52. package/cli/selftune/memory/writer.ts +447 -0
  53. package/cli/selftune/monitoring/watch.ts +25 -2
  54. package/cli/selftune/status.ts +17 -13
  55. package/cli/selftune/types.ts +377 -5
  56. package/cli/selftune/utils/frontmatter.ts +217 -0
  57. package/cli/selftune/utils/llm-call.ts +29 -3
  58. package/cli/selftune/utils/transcript.ts +35 -0
  59. package/cli/selftune/utils/trigger-check.ts +89 -0
  60. package/cli/selftune/utils/tui.ts +156 -0
  61. package/dashboard/index.html +569 -8
  62. package/package.json +8 -4
  63. package/skill/SKILL.md +124 -8
  64. package/skill/Workflows/AutoActivation.md +144 -0
  65. package/skill/Workflows/Badge.md +118 -0
  66. package/skill/Workflows/Baseline.md +121 -0
  67. package/skill/Workflows/Composability.md +100 -0
  68. package/skill/Workflows/Contribute.md +91 -0
  69. package/skill/Workflows/Cron.md +155 -0
  70. package/skill/Workflows/Dashboard.md +203 -0
  71. package/skill/Workflows/Doctor.md +37 -1
  72. package/skill/Workflows/Evals.md +69 -1
  73. package/skill/Workflows/EvolutionMemory.md +152 -0
  74. package/skill/Workflows/Evolve.md +111 -6
  75. package/skill/Workflows/EvolveBody.md +159 -0
  76. package/skill/Workflows/ImportSkillsBench.md +111 -0
  77. package/skill/Workflows/Ingest.md +117 -3
  78. package/skill/Workflows/Initialize.md +57 -3
  79. package/skill/Workflows/Replay.md +70 -0
  80. package/skill/Workflows/Rollback.md +20 -1
  81. package/skill/Workflows/UnitTest.md +138 -0
  82. package/skill/Workflows/Watch.md +22 -0
  83. package/skill/settings_snippet.json +23 -0
  84. package/templates/activation-rules-default.json +27 -0
  85. package/templates/multi-skill-settings.json +64 -0
  86. package/templates/single-skill-settings.json +58 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Default activation rules for the auto-activate hook.
3
+ *
4
+ * Each rule evaluates session context and returns a suggestion string
5
+ * (or null if the rule doesn't fire). Rules must be pure functions
6
+ * that read from the filesystem — no network calls, no imports from
7
+ * evolution/monitoring/grading layers.
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import type { ActivationContext, ActivationRule } from "./types.js";
13
+ import { readJsonl } from "./utils/jsonl.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Rule: post-session diagnostic
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const postSessionDiagnostic: ActivationRule = {
20
+ id: "post-session-diagnostic",
21
+ description: "Suggest `selftune last` when session has >2 unmatched queries",
22
+ evaluate(ctx: ActivationContext): string | null {
23
+ // Count queries for this session
24
+ const queries = readJsonl<{ session_id: string; query: string }>(ctx.query_log_path);
25
+ const sessionQueries = queries.filter((q) => q.session_id === ctx.session_id);
26
+
27
+ if (sessionQueries.length === 0) return null;
28
+
29
+ // Count skill usages for this session (skill log is in the same dir as query log)
30
+ const skillLogPath = join(dirname(ctx.query_log_path), "skill_usage_log.jsonl");
31
+ const skillUsages = existsSync(skillLogPath)
32
+ ? readJsonl<{ session_id: string }>(skillLogPath).filter(
33
+ (s) => s.session_id === ctx.session_id,
34
+ )
35
+ : [];
36
+
37
+ const unmatchedCount = sessionQueries.length - skillUsages.length;
38
+
39
+ if (unmatchedCount > 2) {
40
+ return `Run \`selftune last\` — ${unmatchedCount} unmatched queries detected in this session.`;
41
+ }
42
+
43
+ return null;
44
+ },
45
+ };
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Rule: grading threshold breach
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const gradingThresholdBreach: ActivationRule = {
52
+ id: "grading-threshold-breach",
53
+ description: "Suggest `selftune evolve` when session pass rate < 0.6",
54
+ evaluate(ctx: ActivationContext): string | null {
55
+ // Look for grading results in the selftune dir
56
+ const gradingDir = join(ctx.selftune_dir, "grading");
57
+ if (!existsSync(gradingDir)) return null;
58
+
59
+ try {
60
+ const files = readdirSync(gradingDir).filter(
61
+ (f) => f.startsWith("result-") && f.endsWith(".json"),
62
+ );
63
+
64
+ for (const file of files) {
65
+ const content = readFileSync(join(gradingDir, file), "utf-8");
66
+ const result = JSON.parse(content) as {
67
+ session_id: string;
68
+ summary?: { pass_rate: number };
69
+ };
70
+
71
+ if (result.session_id === ctx.session_id && result.summary) {
72
+ if (result.summary.pass_rate < 0.6) {
73
+ return `Run \`selftune evolve\` — session pass rate is ${(result.summary.pass_rate * 100).toFixed(0)}% (below 60% threshold).`;
74
+ }
75
+ }
76
+ }
77
+ } catch {
78
+ // fail-open
79
+ }
80
+
81
+ return null;
82
+ },
83
+ };
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Rule: stale evolution
87
+ // ---------------------------------------------------------------------------
88
+
89
+ const staleEvolution: ActivationRule = {
90
+ id: "stale-evolution",
91
+ description:
92
+ "Suggest `selftune evolve` when no evolution in >7 days and pending false negatives exist",
93
+ evaluate(ctx: ActivationContext): string | null {
94
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
95
+
96
+ // Check last evolution timestamp
97
+ const auditEntries = readJsonl<{ timestamp: string; action: string }>(
98
+ ctx.evolution_audit_log_path,
99
+ );
100
+
101
+ if (auditEntries.length === 0) {
102
+ // No evolution has ever run — check for false negatives
103
+ return checkFalseNegatives(ctx)
104
+ ? "Run `selftune evolve` — no evolution history found and pending false negatives exist."
105
+ : null;
106
+ }
107
+
108
+ const lastEntry = auditEntries[auditEntries.length - 1];
109
+ const lastTimestamp = new Date(lastEntry.timestamp).getTime();
110
+ const ageMs = Date.now() - lastTimestamp;
111
+
112
+ if (ageMs > SEVEN_DAYS_MS && checkFalseNegatives(ctx)) {
113
+ return `Run \`selftune evolve\` — no evolution in >7 days and pending false negatives detected.`;
114
+ }
115
+
116
+ return null;
117
+ },
118
+ };
119
+
120
+ function checkFalseNegatives(ctx: ActivationContext): boolean {
121
+ const fnPath = join(ctx.selftune_dir, "false-negatives", "pending.json");
122
+ if (!existsSync(fnPath)) return false;
123
+
124
+ try {
125
+ const data = JSON.parse(readFileSync(fnPath, "utf-8"));
126
+ return Array.isArray(data) && data.length > 0;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Rule: regression detected
134
+ // ---------------------------------------------------------------------------
135
+
136
+ const regressionDetected: ActivationRule = {
137
+ id: "regression-detected",
138
+ description: "Suggest `selftune rollback` when watch snapshot shows regression",
139
+ evaluate(ctx: ActivationContext): string | null {
140
+ const snapshotPath = join(ctx.selftune_dir, "monitoring", "latest-snapshot.json");
141
+ if (!existsSync(snapshotPath)) return null;
142
+
143
+ try {
144
+ const snapshot = JSON.parse(readFileSync(snapshotPath, "utf-8")) as {
145
+ regression_detected: boolean;
146
+ skill_name?: string;
147
+ pass_rate?: number;
148
+ };
149
+
150
+ if (snapshot.regression_detected) {
151
+ const skillInfo = snapshot.skill_name ? ` for skill "${snapshot.skill_name}"` : "";
152
+ return `Run \`selftune rollback\` — regression detected${skillInfo}.`;
153
+ }
154
+ } catch {
155
+ // fail-open
156
+ }
157
+
158
+ return null;
159
+ },
160
+ };
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Exported defaults
164
+ // ---------------------------------------------------------------------------
165
+
166
+ export const DEFAULT_RULES: ActivationRule[] = [
167
+ postSessionDiagnostic,
168
+ gradingThresholdBreach,
169
+ staleEvolution,
170
+ regressionDetected,
171
+ ];
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Badge data computation for selftune skill health badges.
3
+ *
4
+ * Maps SkillStatus into display-ready BadgeData with color coding,
5
+ * trend arrows, and formatted messages. Pure functions, zero deps.
6
+ */
7
+
8
+ import type { SkillStatus, StatusResult } from "../status.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface BadgeData {
15
+ label: string;
16
+ passRate: number | null;
17
+ trend: "up" | "down" | "stable" | "unknown";
18
+ status: "HEALTHY" | "WARNING" | "CRITICAL" | "UNKNOWN";
19
+ color: string;
20
+ message: string;
21
+ }
22
+
23
+ export type BadgeFormat = "svg" | "markdown" | "url";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Constants
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export const BADGE_THRESHOLDS = {
30
+ /** Above this → green */
31
+ GREEN: 0.8,
32
+ /** At or above this → yellow; below → red */
33
+ YELLOW: 0.6,
34
+ } as const;
35
+
36
+ export const BADGE_COLORS = {
37
+ GREEN: "#4c1",
38
+ YELLOW: "#dfb317",
39
+ RED: "#e05d44",
40
+ GRAY: "#9f9f9f",
41
+ } as const;
42
+
43
+ export const TREND_ARROWS: Record<BadgeData["trend"], string> = {
44
+ up: "\u2191",
45
+ down: "\u2193",
46
+ stable: "\u2192",
47
+ unknown: "",
48
+ };
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // computeBadgeData
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Convert a SkillStatus into display-ready badge data.
56
+ *
57
+ * Color thresholds:
58
+ * - green (#4c1) passRate > 0.8
59
+ * - yellow (#dfb317) passRate 0.6 - 0.8 (inclusive)
60
+ * - red (#e05d44) passRate < 0.6
61
+ * - gray (#9f9f9f) passRate is null (no data)
62
+ */
63
+ export function computeBadgeData(skill: SkillStatus): BadgeData {
64
+ const { passRate, trend, status } = skill;
65
+
66
+ let color: string;
67
+ let message: string;
68
+
69
+ if (passRate === null) {
70
+ color = BADGE_COLORS.GRAY;
71
+ message = "no data";
72
+ } else {
73
+ if (passRate > BADGE_THRESHOLDS.GREEN) {
74
+ color = BADGE_COLORS.GREEN;
75
+ } else if (passRate >= BADGE_THRESHOLDS.YELLOW) {
76
+ color = BADGE_COLORS.YELLOW;
77
+ } else {
78
+ color = BADGE_COLORS.RED;
79
+ }
80
+
81
+ const pct = `${Math.round(passRate * 100)}%`;
82
+ const arrow = TREND_ARROWS[trend];
83
+ message = arrow ? `${pct} ${arrow}` : pct;
84
+ }
85
+
86
+ return {
87
+ label: "Skill Health",
88
+ passRate,
89
+ trend,
90
+ status,
91
+ color,
92
+ message,
93
+ };
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // findSkillBadgeData
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Find a skill by name in a StatusResult and return its BadgeData,
102
+ * or null if the skill is not found.
103
+ */
104
+ export function findSkillBadgeData(result: StatusResult, name: string): BadgeData | null {
105
+ const skill = result.skills.find((s) => s.name === name);
106
+ if (!skill) return null;
107
+ return computeBadgeData(skill);
108
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * SVG renderer and format router for selftune skill health badges.
3
+ *
4
+ * Generates shields.io flat-style SVG badges using template literals.
5
+ * Uses a per-character width table for Verdana 11px text width estimation.
6
+ * Zero external dependencies, pure functions only.
7
+ */
8
+
9
+ import type { BadgeData, BadgeFormat } from "./badge-data.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Character width table (Verdana 11px)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const CHAR_WIDTHS: Record<string, number> = {
16
+ " ": 3.3,
17
+ "!": 3.3,
18
+ "%": 7.3,
19
+ "(": 3.6,
20
+ ")": 3.6,
21
+ "+": 7.3,
22
+ "-": 3.9,
23
+ ".": 3.3,
24
+ "/": 3.6,
25
+ "0": 6.6,
26
+ "1": 6.6,
27
+ "2": 6.6,
28
+ "3": 6.6,
29
+ "4": 6.6,
30
+ "5": 6.6,
31
+ "6": 6.6,
32
+ "7": 6.6,
33
+ "8": 6.6,
34
+ "9": 6.6,
35
+ ":": 3.3,
36
+ A: 7.5,
37
+ B: 7.5,
38
+ C: 7.2,
39
+ D: 7.8,
40
+ E: 6.8,
41
+ F: 6.3,
42
+ G: 7.8,
43
+ H: 7.8,
44
+ I: 3.0,
45
+ J: 5.0,
46
+ K: 7.2,
47
+ L: 6.2,
48
+ M: 8.9,
49
+ N: 7.8,
50
+ O: 7.8,
51
+ P: 6.6,
52
+ Q: 7.8,
53
+ R: 7.2,
54
+ S: 7.2,
55
+ T: 6.5,
56
+ U: 7.8,
57
+ V: 7.2,
58
+ W: 10.0,
59
+ X: 6.8,
60
+ Y: 6.5,
61
+ Z: 6.8,
62
+ a: 6.2,
63
+ b: 6.6,
64
+ c: 5.6,
65
+ d: 6.6,
66
+ e: 6.2,
67
+ f: 3.6,
68
+ g: 6.6,
69
+ h: 6.6,
70
+ i: 2.8,
71
+ j: 2.8,
72
+ k: 6.2,
73
+ l: 2.8,
74
+ m: 10.0,
75
+ n: 6.6,
76
+ o: 6.6,
77
+ p: 6.6,
78
+ q: 6.6,
79
+ r: 3.9,
80
+ s: 5.6,
81
+ t: 3.6,
82
+ u: 6.6,
83
+ v: 6.2,
84
+ w: 8.9,
85
+ x: 5.9,
86
+ y: 5.9,
87
+ z: 5.6,
88
+ "\u2191": 6.6,
89
+ "\u2193": 6.6,
90
+ "\u2192": 6.6,
91
+ };
92
+
93
+ const DEFAULT_CHAR_WIDTH = 6.8;
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Logo constants
97
+ // ---------------------------------------------------------------------------
98
+
99
+ const LOGO_SIZE = 14;
100
+ const LOGO_PAD = 3; // gap between logo and text
101
+ const LOGO_EXTRA = LOGO_SIZE + LOGO_PAD; // 17px added to label section
102
+
103
+ const LOGO_SVG_BASE64 =
104
+ "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNTAiIGhlaWdodD0iMjUwIiB2aWV3Qm94PSIwIDAgMjUwIDI1MCIgZmlsbD0ibm9uZSI+CjxwYXRoIGQ9Ik0gMTkwLjE2LDMxLjQ5IEMgMTg3LjkxLDI5Ljg4IDE4NC41MSwzMi4xOSAxODUuODgsMzUuMTYgQyAxODYuMzEsMzYuMTEgMTg3LjA4LDM2LjU0IDE4Ny43MSwzNy4wMSBDIDIxOC43NSw1OS44NiAyMzcuNjMsOTIuNzEgMjM3LjYzLDEyOC44MiBDIDIzNy42MywxNzUuOTkgMjA1LjEyLDIxOC41NiAxNTMuODIsMjM0LjY5IEMgMTQ5Ljg5LDIzNS45MyAxNTAuOTEsMjQxLjcxIDE1NC45MSwyNDAuNjYgQyAyMDUuOTgsMjI2Ljk2IDI0My4wMSwxODEuOTQgMjQzLDEyOC40NSBDIDI0Mi45OSw5MC44NyAyMjMuNDcsNTYuMTggMTkwLjE2LDMxLjQ5IFoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTSAxMjUuMTksMjQzLjkxIEMgMTM4LjA4LDI0My45MSAxNDcuMTgsMjM2LjQ0IDE1MS4yMSwyMjUuMDEgQyAxOTMuNzIsMjE3Ljc5IDIyNi45OCwxODQuMDIgMjI2Ljk4LDE0MC44MSBDIDIyNi45OCwxMjEuMTcgMjE5LjgyLDEwMy43OCAyMDkuOTMsODcuMDQgQyAxOTEuNDIsNTUuNDUgMTY1LjE1LDM0LjcyIDExNy43MSwyOC42NSBDIDExMi45MSwyOC4wNCAxMTMuNzcsMzQuMzUgMTE3LjE5LDM0LjgyIEMgMTYxLjY3LDM5LjMzIDE4NS44NCw1Ni43MSAyMDMuNzYsODYuNDIgQyAyMTMuODcsMTAzLjY4IDIyMC42OCwxMTkuNjEgMjIwLjY4LDE0MC44MSBDIDIyMC42OCwxNzkuOTYgMTkwLjgxLDIxMS45NSAxNDguNzEsMjE5LjE2IEMgMTQ3LjExLDIxOS40NyAxNDYuMjcsMjIwLjMyIDE0NS45MiwyMjEuOCBDIDE0Mi45NSwyMzEuMTEgMTM1LjcyLDIzOC4wMiAxMjUuMTksMjM3LjY2IEMgNjQuNDgsMjM3LjY2IDExLjY3LDE5MS42MSAxMS42NywxMjcuNTEgQyAxMS42Nyw3OS42MSA0NC44MiwzNi4zOCA5My44OSwyNy43NyBMIDk0LjExLDI3LjczIEwgOTQuMzgsMjYuNjQgQyA5Ny4wNCwxNi42MSAxMDQuNTcsMTEuODIgMTE0LjE5LDExLjgyIEMgMTM0LjEyLDEzLjM2IDE1Mi45MSwxOC4xNSAxNzAuNDgsMjYuMDggQyAxNzEuOTIsMjYuNzggMTczLjgxLDI3LjA5IDE3NC43NiwyNS41OSBDIDE3Ni4wNSwyMy43MiAxNzUuMzEsMjEuMDcgMTczLjAxLDIwLjM0IEMgMTU0Ljc4LDExLjk2IDEzNy4yMSw3LjE3IDExNC40Nyw2IEggMTEzLjUyIEMgMTAxLjkxLDYgOTMuNDYsMTIuMTYgODkuNDksMjEuNzggQyA0Mi4zNiwzMS4yNiA2LjE3LDc0Ljc2IDYuMTcsMTI4LjA4IEMgNi4xNywxOTAuMDUgNTcuOTIsMjQzLjkxIDEyNS4xOSwyNDMuOTEgWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNIDkzLjY3LDQwLjY0IEMgMTAwLjUxLDUyLjA3IDEwOS41NCw1MS4zMyAxMTQuMDUsNTIuMTcgQyAxMjguNzIsNTMuOTEgMTQxLjQ4LDU1Ljc4IDE1Ny4zOCw2Mi4xNiBDIDE2Mi43Miw2NC40NyAxNjIuMjksNTguMTkgMTU5LjE4LDU3LjAxIEMgMTQ1LjExLDUxLjMzIDEzMi40OCw0OS43OSAxMTEuMzEsNDcuNDggQyAxMDEuODMsNDYuMjkgOTUuNDUsNDEuMTggOTMuNzUsMzIuODEgQyA1NS4yMSwzOS40NiAyMi4wNiw3Mi4xNyAyMi4wNiwxMTIuNDggQyAyMi4wNiwxMzEuOTggMzAuMzYsMTQ5LjgyIDQzLjI2LDE2NC40OSBDIDQ2LjIzLDE2Ny41OSA1MC4xOSwxNjQuMTMgNDguMzIsMTYxLjAyIEMgMzYuMjEsMTQ1LjU0IDI4LjQyLDEyOS43OCAyOC40MiwxMTIuNCBDIDI4LjQyLDc5LjExIDU0LjkxLDQ4LjM2IDg5LjkxLDQwLjM2IEMgOTAuNzYsNDAuMTUgOTEuMDQsMzkuODcgOTEuNjIsNDAuMDEgQyA5Mi42Miw0MC4wMSA5My4wNCwzOS42NSA5My42Nyw0MC42NCBaIiBmaWxsPSIjZmZmIi8+CjxwYXRoIGQ9Ik0gMTUyLjcyLDgyLjc3IEMgMTI2LjYxLDgyLjc3IDExMy4wNyw5OS40NCAxMDMuMDEsMTE5LjMzIEMgMTAwLjU2LDEyMy4zNiAxMDMuNzQsMTI1LjAzIDEwNS42MSwxMjMuOTIgQyAxMDcuMTUsMTIzLjIyIDEwNy44OSwxMjEuMDUgMTA4LjczLDExOS42MSBDIDExOC4yMiwxMDIuMTYgMTMwLjMzLDg4LjU2IDE1Mi43Miw4OC41NiBDIDE4MS42Miw4OC41NiAyMDEuOTEsMTE2LjAxIDIwMS45MSwxNDcuMzEgQyAyMDEuOTEsMTc1LjEyIDE4My40NywxOTkuOTYgMTUyLjUxLDIwNS43NSBDIDE1MS44NCwyMDUuOTYgMTUxLjYzLDIwNi4wMyAxNTEuNTYsMjA1LjU0IEMgMTQ3Ljc0LDE5NS4zNyAxMzkuMzYsMTg4LjE1IDEyOC4wNywxODYuNDggQyAxMTMuMiwxODQuMjQgMTAxLjIzLDE4Mi4zNiA4My44LDE3Ni44MSBDIDc5LjMsMTc1LjQ4IDc3LjkxLDE4Mi4zNiA4Mi40MSwxODMuMDkgQyA5Ny4yMSwxODcuNDYgMTA4LjA5LDE4OS40NyAxMjYuMjUsMTkyLjY1IEMgMTM2Ljc4LDE5NC4zMSAxNDUuNDEsMjAxLjcxIDE0Ny4xMSwyMTAuOTUgQyAxNDcuNzQsMjEzLjA1IDE0OS4xMywyMTMuNDEgMTUwLjE1LDIxMy4yNiBDIDE4My43NSwyMDguNjEgMjA4LjI2LDE4MC45MyAyMDguMjYsMTQ3LjI0IEMgMjA4LjI2LDExNS4wNiAxODYuOTQsODIuNzcgMTUyLjcyLDgyLjc3IFoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTSAxMjkuNzcsMTA1LjIxIEMgMTIyLjkzLDExMi4wNSAxMTguOTcsMTIyLjczIDExMy43NywxMzAuNDEgQyAxMTEuMzEsMTMzLjQ1IDExNC41NiwxMzYuNjMgMTE3LjQ2LDEzNC40NiBDIDEyMy43NSwxMjYuMjMgMTI3LjQzLDExNS42MiAxMzUuMTUsMTA4LjcxIEMgMTM4LjIyLDEwNS44MSAxMzQuNzMsMTAxLjA5IDEyOS43NywxMDUuMjEgWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNIDEzNi43OCwxMjAuMzEgQyAxMjcuNzEsMTM2LjcxIDEyMC4xMiwxNTQuOTEgOTMuNzQsMTU0LjkxIEMgNjYuMDcsMTU0LjkxIDQ3Ljc2LDEyOC41MyA0Ny43NiwxMDQuNzggQyA0Ny43Niw4NC40NyA1OC41Nyw2Ni4wOCA3Ny42Niw1Ni4yNSBDIDgyLjIzLDU0LjIxIDc5Ljg1LDQ3Ljc2IDc1LjM0LDQ5LjkzIEMgNTQuNzcsNTkuNzIgNDIuMDEsODAuMTEgNDIuMDEsMTA0LjcxIEMgNDIuMDEsMTMxLjc3IDYxLjg2LDE2MS4zMSA5My42NywxNjEuMzEgQyAxMTQuNzcsMTYxLjMxIDEyOC45MSwxNDcuMjQgMTM5Ljg2LDEyNC4wNiBDIDE0Mi43NiwxMjAuNDUgMTM5LjE1LDExNy43MyAxMzYuNzgsMTIwLjMxIFoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTSAzMC43MywxNTQuNyBDIDI3Ljc2LDE1Mi45NyAyMy44NywxNTUuOTMgMjUuNDEsMTU4Ljc2IEMgNDEuNzMsMTg4LjM2IDY4Ljk0LDE5OS43OSAxMDUuNzUsMjA2LjQxIEMgMTEyLjI1LDIwNy42NiAxMjIuMDcsMjA4Ljc1IDEyMy40NiwyMDkuMDMgQyAxMjguMDcsMjA5Ljk1IDEyOC4wNywyMjAuMTggMTIxLjc4LDIyMC4xOCBDIDEwNy42NCwyMTguOTQgOTIuMDYsMjE1Ljk4IDc2LjIzLDIxMS4zMyBDIDcyLjEzLDIxMC4yNCA3MS4wNCwyMTYuNjkgNzUuMjcsMjE3LjY0IEMgOTAuNDEsMjIyLjIyIDEwMy45NSwyMjQuNzQgMTIwLjQ3LDIyNi41NCBDIDEzMy43MywyMjYuNTQgMTM2LjU2LDIwOS4wMyAxMjYuMDMsMjAzLjM4IEMgMTIzLjc1LDIwMi4xMyAxMjIuNzMsMjAyLjU2IDExMi4wNCwyMDAuNzYgQyA3OC4wOSwxOTUuMDQgNTQuMDYsMTg4Ljk4IDMyLjEyLDE1NS42NSBDIDMxLjc3LDE1NS4yMyAzMS4yOCwxNTQuOTEgMzAuNzMsMTU0LjcgWiIgZmlsbD0iI2ZmZiIvPgo8L3N2Zz4=";
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Text width estimation
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function measureText(text: string): number {
111
+ let width = 0;
112
+ for (const ch of text) {
113
+ width += CHAR_WIDTHS[ch] ?? DEFAULT_CHAR_WIDTH;
114
+ }
115
+ return width;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // SVG escaping
120
+ // ---------------------------------------------------------------------------
121
+
122
+ function escapeXml(text: string): string {
123
+ return text
124
+ .replace(/&/g, "&amp;")
125
+ .replace(/</g, "&lt;")
126
+ .replace(/>/g, "&gt;")
127
+ .replace(/"/g, "&quot;")
128
+ .replace(/'/g, "&apos;");
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // renderBadgeSvg
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Render a shields.io flat-style SVG badge from BadgeData.
137
+ *
138
+ * Layout: [label (gray #555)] [value (colored)]
139
+ * Each half has 10px padding on each side, 1px gap between halves.
140
+ */
141
+ export function renderBadgeSvg(data: BadgeData): string {
142
+ const labelText = data.label;
143
+ const valueText = data.message;
144
+
145
+ const labelTextWidth = measureText(labelText);
146
+ const valueTextWidth = measureText(valueText);
147
+
148
+ // 10px padding on each side of text + logo space in label
149
+ const labelWidth = Math.round(labelTextWidth + 20 + LOGO_EXTRA);
150
+ const valueWidth = Math.round(valueTextWidth + 20);
151
+ const totalWidth = labelWidth + 1 + valueWidth; // 1px gap
152
+
153
+ const labelTextX = (labelWidth + LOGO_EXTRA) / 2;
154
+ const valueX = labelWidth + 1 + valueWidth / 2;
155
+
156
+ const height = 20;
157
+ const labelColor = "#555";
158
+ const valueColor = data.color;
159
+
160
+ const escapedLabel = escapeXml(labelText);
161
+ const escapedValue = escapeXml(valueText);
162
+
163
+ return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${totalWidth}" height="${height}" role="img" aria-label="${escapedLabel}: ${escapedValue}">
164
+ <linearGradient id="b" x2="0" y2="100%">
165
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
166
+ <stop offset="1" stop-opacity=".1"/>
167
+ </linearGradient>
168
+ <clipPath id="a">
169
+ <rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
170
+ </clipPath>
171
+ <g clip-path="url(#a)">
172
+ <rect width="${labelWidth}" height="${height}" fill="${labelColor}"/>
173
+ <rect x="${labelWidth + 1}" width="${valueWidth}" height="${height}" fill="${valueColor}"/>
174
+ <rect width="${totalWidth}" height="${height}" fill="url(#b)"/>
175
+ </g>
176
+ <image x="3" y="3" width="${LOGO_SIZE}" height="${LOGO_SIZE}" xlink:href="${LOGO_SVG_BASE64}"/>
177
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
178
+ <text x="${labelTextX}" y="15" fill="#010101" fill-opacity=".3">${escapedLabel}</text>
179
+ <text x="${labelTextX}" y="14">${escapedLabel}</text>
180
+ <text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${escapedValue}</text>
181
+ <text x="${valueX}" y="14">${escapedValue}</text>
182
+ </g>
183
+ </svg>`;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // formatBadgeOutput
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Route badge data to the requested output format.
192
+ *
193
+ * - "svg" local SVG string via renderBadgeSvg
194
+ * - "markdown" shields.io markdown image link
195
+ * - "url" shields.io badge URL
196
+ */
197
+ export function formatBadgeOutput(data: BadgeData, skillName: string, format: BadgeFormat): string {
198
+ if (format === "svg") {
199
+ return renderBadgeSvg(data);
200
+ }
201
+
202
+ const label = encodeURIComponent(data.label);
203
+ const message = encodeURIComponent(data.message);
204
+ const color = data.color.replace("#", "");
205
+ const url = `https://img.shields.io/badge/${label}-${message}-${color}`;
206
+
207
+ if (format === "markdown") {
208
+ return `![Skill Health: ${skillName}](${url})`;
209
+ }
210
+
211
+ return url;
212
+ }
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * selftune badge -- Generate skill health badges for READMEs.
4
+ *
5
+ * Usage:
6
+ * selftune badge --skill <name> [--format svg|markdown|url] [--output <path>]
7
+ */
8
+
9
+ import { writeFileSync } from "node:fs";
10
+ import { parseArgs } from "node:util";
11
+ import { EVOLUTION_AUDIT_LOG, QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "../constants.js";
12
+ import { doctor } from "../observability.js";
13
+ import { computeStatus } from "../status.js";
14
+ import type {
15
+ EvolutionAuditEntry,
16
+ QueryLogRecord,
17
+ SessionTelemetryRecord,
18
+ SkillUsageRecord,
19
+ } from "../types.js";
20
+ import { readJsonl } from "../utils/jsonl.js";
21
+ import type { BadgeFormat } from "./badge-data.js";
22
+ import { findSkillBadgeData } from "./badge-data.js";
23
+ import { formatBadgeOutput } from "./badge-svg.js";
24
+
25
+ const HELP = `selftune badge \u2014 Generate skill health badges
26
+
27
+ Usage: selftune badge --skill <name> [options]
28
+
29
+ Options:
30
+ --skill <name> Skill name (required)
31
+ --format <type> Output format: svg, markdown, url (default: svg)
32
+ --output <path> Write to file instead of stdout
33
+ --help Show this help`;
34
+
35
+ const VALID_FORMATS = new Set<BadgeFormat>(["svg", "markdown", "url"]);
36
+
37
+ export function cliMain(): void {
38
+ const { values } = parseArgs({
39
+ args: process.argv.slice(2),
40
+ options: {
41
+ skill: { type: "string" },
42
+ format: { type: "string" },
43
+ output: { type: "string" },
44
+ help: { type: "boolean" },
45
+ },
46
+ strict: true,
47
+ });
48
+
49
+ if (values.help) {
50
+ console.log(HELP);
51
+ return;
52
+ }
53
+
54
+ if (!values.skill) {
55
+ console.error("Error: --skill is required\n");
56
+ console.error(HELP);
57
+ process.exit(1);
58
+ }
59
+
60
+ if (values.format && !VALID_FORMATS.has(values.format as BadgeFormat)) {
61
+ console.error(`Error: invalid format '${values.format}'. Must be one of: svg, markdown, url\n`);
62
+ console.error(HELP);
63
+ process.exit(1);
64
+ }
65
+
66
+ const format: BadgeFormat =
67
+ values.format && VALID_FORMATS.has(values.format as BadgeFormat)
68
+ ? (values.format as BadgeFormat)
69
+ : "svg";
70
+
71
+ // Read log files
72
+ const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
73
+ const skillRecords = readJsonl<SkillUsageRecord>(SKILL_LOG);
74
+ const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
75
+ const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
76
+
77
+ // Run doctor for system health
78
+ const doctorResult = doctor();
79
+
80
+ // Compute status
81
+ const result = computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
82
+
83
+ // Find skill badge data
84
+ const badgeData = findSkillBadgeData(result, values.skill);
85
+ if (!badgeData) {
86
+ console.error(`Skill not found: ${values.skill}`);
87
+ process.exit(1);
88
+ }
89
+
90
+ // Generate output
91
+ const output = formatBadgeOutput(badgeData, values.skill, format);
92
+
93
+ if (values.output) {
94
+ writeFileSync(values.output, output, "utf-8");
95
+ console.log(`Badge written to ${values.output}`);
96
+ } else {
97
+ console.log(output);
98
+ }
99
+ }
100
+
101
+ if (import.meta.main) {
102
+ cliMain();
103
+ }
@@ -15,6 +15,12 @@ export const SKILL_LOG = join(LOG_DIR, "skill_usage_log.jsonl");
15
15
  export const QUERY_LOG = join(LOG_DIR, "all_queries_log.jsonl");
16
16
  export const EVOLUTION_AUDIT_LOG = join(LOG_DIR, "evolution_audit_log.jsonl");
17
17
 
18
+ /** Evolution memory directory — human-readable session context that survives resets. */
19
+ export const MEMORY_DIR = join(SELFTUNE_CONFIG_DIR, "memory");
20
+ export const CONTEXT_PATH = join(MEMORY_DIR, "context.md");
21
+ export const PLAN_PATH = join(MEMORY_DIR, "plan.md");
22
+ export const DECISIONS_PATH = join(MEMORY_DIR, "decisions.md");
23
+
18
24
  /** Tool names Claude Code uses. */
19
25
  export const KNOWN_TOOLS = new Set([
20
26
  "Read",
@@ -62,4 +68,72 @@ export const REQUIRED_FIELDS: Record<string, Set<string>> = {
62
68
  };
63
69
 
64
70
  /** Agent CLI candidates in detection order. */
65
- export const AGENT_CANDIDATES = ["claude", "codex", "opencode"] as const;
71
+ export const AGENT_CANDIDATES = ["claude", "codex", "opencode", "openclaw"] as const;
72
+
73
+ /** Path for user-defined activation rule overrides. */
74
+ export const ACTIVATION_RULES_PATH = join(SELFTUNE_CONFIG_DIR, "activation-rules.json");
75
+
76
+ /** Per-session state file pattern (interpolate session_id). */
77
+ export const SESSION_STATE_DIR = SELFTUNE_CONFIG_DIR;
78
+
79
+ /** Build a session state file path from a session ID. */
80
+ export function sessionStatePath(sessionId: string): string {
81
+ // Sanitize session ID to be filesystem-safe
82
+ const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
83
+ return join(SESSION_STATE_DIR, `session-state-${safe}.json`);
84
+ }
85
+
86
+ /** Claude Code settings file path. */
87
+ export const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
88
+
89
+ /** Path to Claude Code projects directory containing session transcripts. */
90
+ export const CLAUDE_CODE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
91
+
92
+ /** Marker file tracking which Claude Code sessions have been ingested. */
93
+ export const CLAUDE_CODE_MARKER = join(homedir(), ".claude", "claude_code_ingested_sessions.json");
94
+
95
+ /** OpenClaw agents directory containing session data. */
96
+ export const OPENCLAW_AGENTS_DIR = join(homedir(), ".openclaw", "agents");
97
+
98
+ /** Marker file tracking which OpenClaw sessions have been ingested. */
99
+ export const OPENCLAW_INGEST_MARKER = join(SELFTUNE_CONFIG_DIR, "openclaw-ingest-marker.json");
100
+
101
+ /** Default output directory for contribution bundles. */
102
+ export const CONTRIBUTIONS_DIR = join(SELFTUNE_CONFIG_DIR, "contributions");
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Sanitization constants (for contribute command)
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /** Regex patterns for detecting secrets that must be redacted. */
109
+ export const SECRET_PATTERNS = [
110
+ /sk-[a-zA-Z0-9]{20,}/g, // OpenAI / Anthropic API keys
111
+ /ghp_[a-zA-Z0-9]{36,}/g, // GitHub personal access tokens
112
+ /gho_[a-zA-Z0-9]{36,}/g, // GitHub OAuth tokens
113
+ /github_pat_[a-zA-Z0-9_]{22,}/g, // GitHub fine-grained PATs
114
+ /AKIA[A-Z0-9]{16}/g, // AWS access key IDs
115
+ /xoxb-[a-zA-Z0-9-]+/g, // Slack bot tokens
116
+ /xoxp-[a-zA-Z0-9-]+/g, // Slack user tokens
117
+ /xoxs-[a-zA-Z0-9-]+/g, // Slack session tokens
118
+ /eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}/g, // JWTs
119
+ /npm_[a-zA-Z0-9]{36}/g, // npm tokens
120
+ /pypi-[a-zA-Z0-9]{36,}/g, // PyPI tokens
121
+ ] as const;
122
+
123
+ /** Regex for file paths (Unix and Windows). */
124
+ export const FILE_PATH_PATTERN = /(?:\/[\w.-]+){2,}|[A-Z]:\\[\w\\.-]+/g;
125
+
126
+ /** Regex for email addresses. */
127
+ export const EMAIL_PATTERN = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g;
128
+
129
+ /** Regex for IP addresses (v4). */
130
+ export const IP_PATTERN = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g;
131
+
132
+ /** Regex for camelCase/PascalCase identifiers longer than 8 chars (aggressive mode). */
133
+ export const IDENTIFIER_PATTERN = /\b[a-z][a-zA-Z0-9]{8,}\b|\b[A-Z][a-zA-Z0-9]{8,}\b/g;
134
+
135
+ /** Regex for import/require/from module paths (aggressive mode). */
136
+ export const MODULE_PATTERN = /(?:import|require|from)\s+["']([^"']+)["']/g;
137
+
138
+ /** Max query length for aggressive sanitization. */
139
+ export const AGGRESSIVE_MAX_QUERY_LENGTH = 200;