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,440 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * OpenClaw session ingestor: openclaw-ingest.ts
4
+ *
5
+ * Ingests OpenClaw session history from JSONL files into our shared
6
+ * skill eval log format.
7
+ *
8
+ * OpenClaw stores sessions as JSONL at:
9
+ * ~/.openclaw/agents/<agentId>/sessions/<sessionId>.jsonl
10
+ *
11
+ * Each JSONL file has:
12
+ * Line 1 (session header): {"type":"session","version":5,"id":"<uuid>","timestamp":"<iso>","cwd":"<path>"}
13
+ * Line 2+ (messages): {"role":"user|assistant|toolResult","content":[...],"timestamp":<ms>}
14
+ *
15
+ * Usage:
16
+ * bun openclaw-ingest.ts
17
+ * bun openclaw-ingest.ts --since 2026-01-01
18
+ * bun openclaw-ingest.ts --agents-dir /custom/path
19
+ * bun openclaw-ingest.ts --dry-run
20
+ * bun openclaw-ingest.ts --force
21
+ * bun openclaw-ingest.ts --verbose
22
+ */
23
+
24
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { basename, join } from "node:path";
27
+ import { parseArgs } from "node:util";
28
+ import {
29
+ OPENCLAW_AGENTS_DIR,
30
+ OPENCLAW_INGEST_MARKER,
31
+ QUERY_LOG,
32
+ SKILL_LOG,
33
+ TELEMETRY_LOG,
34
+ } from "../constants.js";
35
+ import type { QueryLogRecord, SkillUsageRecord } from "../types.js";
36
+ import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
37
+
38
+ export interface SessionFile {
39
+ agentId: string;
40
+ sessionId: string;
41
+ filePath: string;
42
+ timestamp: number; // epoch ms from file stat or header
43
+ }
44
+
45
+ export interface ParsedSession {
46
+ timestamp: string;
47
+ session_id: string;
48
+ source: string;
49
+ transcript_path: string;
50
+ cwd: string;
51
+ last_user_query: string;
52
+ query: string;
53
+ tool_calls: Record<string, number>;
54
+ total_tool_calls: number;
55
+ bash_commands: string[];
56
+ skills_triggered: string[];
57
+ assistant_turns: number;
58
+ errors_encountered: number;
59
+ transcript_chars: number;
60
+ }
61
+
62
+ /**
63
+ * Scan <agentsDir>/<agentId>/sessions/*.jsonl for OpenClaw session files.
64
+ * Reads line 1 of each file to get the session header with id and timestamp.
65
+ * If sinceTs (epoch ms) is provided, skips sessions older than that.
66
+ */
67
+ export function findOpenClawSessions(agentsDir: string, sinceTs: number | null): SessionFile[] {
68
+ if (!existsSync(agentsDir)) return [];
69
+
70
+ const results: SessionFile[] = [];
71
+ let agentDirs: string[];
72
+
73
+ try {
74
+ agentDirs = readdirSync(agentsDir);
75
+ } catch {
76
+ return [];
77
+ }
78
+
79
+ for (const agentId of agentDirs) {
80
+ const sessionsDir = join(agentsDir, agentId, "sessions");
81
+ if (!existsSync(sessionsDir)) continue;
82
+
83
+ let files: string[];
84
+ try {
85
+ files = readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
86
+ } catch {
87
+ continue;
88
+ }
89
+
90
+ for (const file of files) {
91
+ const filePath = join(sessionsDir, file);
92
+ try {
93
+ const content = readFileSync(filePath, "utf-8");
94
+ const firstLine = content.split("\n")[0]?.trim();
95
+ if (!firstLine) continue;
96
+
97
+ const header = JSON.parse(firstLine);
98
+ if (header.type !== "session") continue;
99
+
100
+ const sessionId = header.id ?? basename(file, ".jsonl");
101
+ const headerTs = header.timestamp ? new Date(header.timestamp).getTime() : 0;
102
+ const fileTs = headerTs || statSync(filePath).mtimeMs;
103
+
104
+ if (sinceTs !== null && fileTs < sinceTs) continue;
105
+
106
+ results.push({
107
+ agentId,
108
+ sessionId,
109
+ filePath,
110
+ timestamp: fileTs,
111
+ });
112
+ } catch {
113
+ // Skip files that can't be read or parsed
114
+ }
115
+ }
116
+ }
117
+
118
+ return results;
119
+ }
120
+
121
+ /**
122
+ * Parse an OpenClaw session JSONL file into a ParsedSession.
123
+ *
124
+ * Line 1: session header with id, timestamp, cwd
125
+ * Lines 2+: messages with role user/assistant/toolResult
126
+ */
127
+ export function parseOpenClawSession(filePath: string, skillNames: Set<string>): ParsedSession {
128
+ const empty: ParsedSession = {
129
+ timestamp: "",
130
+ session_id: "",
131
+ source: "openclaw",
132
+ transcript_path: filePath,
133
+ cwd: "",
134
+ last_user_query: "",
135
+ query: "",
136
+ tool_calls: {},
137
+ total_tool_calls: 0,
138
+ bash_commands: [],
139
+ skills_triggered: [],
140
+ assistant_turns: 0,
141
+ errors_encountered: 0,
142
+ transcript_chars: 0,
143
+ };
144
+
145
+ let content: string;
146
+ try {
147
+ content = readFileSync(filePath, "utf-8");
148
+ } catch {
149
+ return empty;
150
+ }
151
+
152
+ empty.transcript_chars = content.length;
153
+ const lines = content.split("\n").filter((l) => l.trim());
154
+
155
+ if (lines.length === 0) return empty;
156
+
157
+ // Parse session header (line 1)
158
+ let header: Record<string, unknown>;
159
+ try {
160
+ header = JSON.parse(lines[0]);
161
+ } catch {
162
+ return empty;
163
+ }
164
+
165
+ if (header.type !== "session") return empty;
166
+
167
+ const sessionId = (header.id as string) ?? "";
168
+ const timestamp = (header.timestamp as string) ?? "";
169
+ const cwd = (header.cwd as string) ?? "";
170
+
171
+ const toolCalls: Record<string, number> = {};
172
+ const bashCommands: string[] = [];
173
+ const skillsTriggered: string[] = [];
174
+ let firstUserQuery = "";
175
+ let lastUserQuery = "";
176
+ let assistantTurns = 0;
177
+ let errors = 0;
178
+
179
+ // Parse messages (lines 2+)
180
+ for (let i = 1; i < lines.length; i++) {
181
+ let msg: Record<string, unknown>;
182
+ try {
183
+ msg = JSON.parse(lines[i]);
184
+ } catch {
185
+ continue;
186
+ }
187
+
188
+ const role = (msg.role as string) ?? "";
189
+ const contentBlocks = normalizeContentBlocks(msg.content);
190
+
191
+ if (role === "user") {
192
+ // Extract text from user messages
193
+ for (const block of contentBlocks) {
194
+ if (block.type === "text") {
195
+ const text = ((block.text as string) ?? "").trim();
196
+ if (text) {
197
+ if (!firstUserQuery) firstUserQuery = text;
198
+ lastUserQuery = text;
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ } else if (role === "assistant") {
204
+ assistantTurns += 1;
205
+
206
+ for (const block of contentBlocks) {
207
+ const blockType = (block.type as string) ?? "";
208
+
209
+ // Handle toolCall and toolUse (alias)
210
+ if (blockType === "toolCall" || blockType === "toolUse") {
211
+ const toolName = (block.name as string) ?? "unknown";
212
+ toolCalls[toolName] = (toolCalls[toolName] ?? 0) + 1;
213
+ const inp = (block.input as Record<string, unknown>) ?? {};
214
+
215
+ // Extract bash commands
216
+ if (["Bash", "bash", "execute_bash"].includes(toolName)) {
217
+ const cmd = ((inp.command as string) ?? (inp.cmd as string) ?? "").trim();
218
+ if (cmd) bashCommands.push(cmd);
219
+ }
220
+
221
+ // Skill detection: file reads of SKILL.md
222
+ if (["Read", "read_file"].includes(toolName)) {
223
+ const fp = (inp.file_path as string) ?? (inp.path as string) ?? "";
224
+ if (basename(fp).toUpperCase() === "SKILL.MD") {
225
+ const skillName = basename(join(fp, ".."));
226
+ if (!skillsTriggered.includes(skillName)) {
227
+ skillsTriggered.push(skillName);
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ // Check text content for skill name mentions
234
+ const textContent = (block.text as string) ?? "";
235
+ for (const skillName of skillNames) {
236
+ if (textContent.includes(skillName) && !skillsTriggered.includes(skillName)) {
237
+ skillsTriggered.push(skillName);
238
+ }
239
+ }
240
+ }
241
+ } else if (role === "toolResult") {
242
+ const blockHasError = contentBlocks.some(
243
+ (block) => block.isError === true || block.is_error === true,
244
+ );
245
+ if (msg.isError === true || blockHasError) {
246
+ errors += 1;
247
+ }
248
+ }
249
+ }
250
+
251
+ return {
252
+ timestamp,
253
+ session_id: sessionId,
254
+ source: "openclaw",
255
+ transcript_path: filePath,
256
+ cwd,
257
+ last_user_query: lastUserQuery || firstUserQuery,
258
+ query: firstUserQuery,
259
+ tool_calls: toolCalls,
260
+ total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
261
+ bash_commands: bashCommands,
262
+ skills_triggered: skillsTriggered,
263
+ assistant_turns: assistantTurns,
264
+ errors_encountered: errors,
265
+ transcript_chars: content.length,
266
+ };
267
+ }
268
+
269
+ /** Normalize message content into an array of content block objects. */
270
+ function normalizeContentBlocks(raw: unknown): Array<Record<string, unknown>> {
271
+ if (Array.isArray(raw)) {
272
+ return raw.filter((b): b is Record<string, unknown> => typeof b === "object" && b !== null);
273
+ }
274
+ if (typeof raw === "string") {
275
+ return [{ type: "text", text: raw }];
276
+ }
277
+ if (typeof raw === "object" && raw !== null) {
278
+ return [raw as Record<string, unknown>];
279
+ }
280
+ return [];
281
+ }
282
+
283
+ const OPENCLAW_SKILL_DIRS = [
284
+ join(homedir(), ".openclaw", "skills"),
285
+ join(process.cwd(), ".agents", "skills"),
286
+ ];
287
+
288
+ /**
289
+ * Find OpenClaw skill names from skill directories.
290
+ * By default checks:
291
+ * <agentsDir>/../skills/ (managed skills)
292
+ * ~/.openclaw/skills/
293
+ * process.cwd()/.agents/skills/ (workspace skills)
294
+ */
295
+ export function findOpenClawSkillNames(
296
+ agentsDir: string,
297
+ extraDirs: string[] = OPENCLAW_SKILL_DIRS,
298
+ ): Set<string> {
299
+ const names = new Set<string>();
300
+ const skillDirs = [join(agentsDir, "..", "skills"), join(agentsDir, "skills"), ...extraDirs];
301
+
302
+ for (const dir of skillDirs) {
303
+ if (!existsSync(dir)) continue;
304
+ try {
305
+ for (const entry of readdirSync(dir)) {
306
+ const skillDir = join(dir, entry);
307
+ try {
308
+ if (statSync(skillDir).isDirectory() && existsSync(join(skillDir, "SKILL.md"))) {
309
+ names.add(entry);
310
+ }
311
+ } catch {
312
+ // skip entries that can't be stat'd
313
+ }
314
+ }
315
+ } catch {
316
+ // skip dirs that can't be listed
317
+ }
318
+ }
319
+ return names;
320
+ }
321
+
322
+ /** Write a parsed session to our shared logs. Same pattern as opencode-ingest. */
323
+ export function writeSession(
324
+ session: ParsedSession,
325
+ dryRun = false,
326
+ queryLogPath: string = QUERY_LOG,
327
+ telemetryLogPath: string = TELEMETRY_LOG,
328
+ skillLogPath: string = SKILL_LOG,
329
+ ): void {
330
+ const { query: prompt, session_id: sessionId, skills_triggered: skills } = session;
331
+
332
+ if (dryRun) {
333
+ console.log(
334
+ ` [DRY] session=${sessionId.slice(0, 12)}... turns=${session.assistant_turns} skills=${JSON.stringify(skills)}`,
335
+ );
336
+ if (prompt) console.log(` query: ${prompt.slice(0, 80)}`);
337
+ return;
338
+ }
339
+
340
+ if (prompt && prompt.length >= 4) {
341
+ const queryRecord: QueryLogRecord = {
342
+ timestamp: session.timestamp,
343
+ session_id: sessionId,
344
+ query: prompt,
345
+ source: session.source,
346
+ };
347
+ appendJsonl(queryLogPath, queryRecord, "all_queries");
348
+ }
349
+
350
+ const { query: _q, ...telemetry } = session;
351
+ appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
352
+
353
+ for (const skillName of skills) {
354
+ const skillRecord: SkillUsageRecord = {
355
+ timestamp: session.timestamp,
356
+ session_id: sessionId,
357
+ skill_name: skillName,
358
+ skill_path: `(openclaw:${skillName})`,
359
+ query: prompt,
360
+ triggered: true,
361
+ source: session.source,
362
+ };
363
+ appendJsonl(skillLogPath, skillRecord, "skill_usage");
364
+ }
365
+ }
366
+
367
+ // --- CLI main ---
368
+ export function cliMain(): void {
369
+ const { values } = parseArgs({
370
+ options: {
371
+ "agents-dir": { type: "string", default: OPENCLAW_AGENTS_DIR },
372
+ since: { type: "string" },
373
+ "dry-run": { type: "boolean", default: false },
374
+ force: { type: "boolean", default: false },
375
+ verbose: { type: "boolean", short: "v", default: false },
376
+ },
377
+ strict: true,
378
+ });
379
+
380
+ const agentsDir = values["agents-dir"] ?? OPENCLAW_AGENTS_DIR;
381
+
382
+ if (!existsSync(agentsDir)) {
383
+ console.log(`OpenClaw agents directory not found: ${agentsDir}`);
384
+ console.log("Is OpenClaw installed? Try --agents-dir to specify a custom location.");
385
+ process.exit(1);
386
+ }
387
+
388
+ let sinceTs: number | null = null;
389
+ if (values.since) {
390
+ const parsed = new Date(`${values.since}T00:00:00Z`);
391
+ if (Number.isNaN(parsed.getTime())) {
392
+ console.error(`[ERROR] Invalid --since date: "${values.since}". Use YYYY-MM-DD format.`);
393
+ process.exit(1);
394
+ }
395
+ sinceTs = parsed.getTime();
396
+ }
397
+
398
+ const skillNames = findOpenClawSkillNames(agentsDir);
399
+ const alreadyIngested = values.force ? new Set<string>() : loadMarker(OPENCLAW_INGEST_MARKER);
400
+ const allSessions = findOpenClawSessions(agentsDir, sinceTs);
401
+
402
+ console.log(`Found ${allSessions.length} total sessions.`);
403
+
404
+ const pending = allSessions.filter((s) => !alreadyIngested.has(s.sessionId));
405
+ console.log(`${pending.length} not yet ingested.`);
406
+
407
+ const newIngested = new Set<string>();
408
+ let ingestedCount = 0;
409
+
410
+ for (const sf of pending) {
411
+ const session = parseOpenClawSession(sf.filePath, skillNames);
412
+
413
+ if (!session.session_id || !session.timestamp) {
414
+ console.log(
415
+ ` [WARN] Skipping session ${sf.sessionId.slice(0, 12)}...: missing session_id or timestamp after parsing`,
416
+ );
417
+ continue;
418
+ }
419
+
420
+ if (values.verbose || values["dry-run"]) {
421
+ console.log(
422
+ ` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${sf.sessionId.slice(0, 12)}...`,
423
+ );
424
+ }
425
+
426
+ writeSession(session, values["dry-run"]);
427
+ newIngested.add(sf.sessionId);
428
+ ingestedCount += 1;
429
+ }
430
+
431
+ if (!values["dry-run"]) {
432
+ saveMarker(OPENCLAW_INGEST_MARKER, new Set([...alreadyIngested, ...newIngested]));
433
+ }
434
+
435
+ console.log(`\nDone. Ingested ${ingestedCount} sessions.`);
436
+ }
437
+
438
+ if (import.meta.main) {
439
+ cliMain();
440
+ }
@@ -10,7 +10,7 @@
10
10
  * selftune init [--agent <type>] [--cli-path <path>] [--force]
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { dirname, join, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
@@ -37,6 +37,7 @@ const VALID_AGENT_TYPES: SelftuneConfig["agent_type"][] = [
37
37
  "claude_code",
38
38
  "codex",
39
39
  "opencode",
40
+ "openclaw",
40
41
  "unknown",
41
42
  ];
42
43
 
@@ -44,6 +45,7 @@ const AGENT_TYPE_CLI_MAP: Record<string, string> = {
44
45
  claude_code: "claude",
45
46
  codex: "codex",
46
47
  opencode: "opencode",
48
+ openclaw: "openclaw",
47
49
  };
48
50
 
49
51
  function agentTypeToCli(agentType: string): string | null {
@@ -82,6 +84,12 @@ export function detectAgentType(
82
84
  return "opencode";
83
85
  }
84
86
 
87
+ // OpenClaw: agents directory or binary
88
+ const openclawDir = join(home, ".openclaw", "agents");
89
+ if (existsSync(openclawDir) || Bun.which("openclaw")) {
90
+ return "openclaw";
91
+ }
92
+
85
93
  return "unknown";
86
94
  }
87
95
 
@@ -147,6 +155,125 @@ export function checkClaudeCodeHooks(settingsPath: string): boolean {
147
155
  }
148
156
  }
149
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // Workspace type detection
160
+ // ---------------------------------------------------------------------------
161
+
162
+ const IGNORE_DIRS = new Set(["node_modules", ".git", ".hg", "dist", "build", ".next", ".cache"]);
163
+
164
+ export interface WorkspaceInfo {
165
+ type: "single-skill" | "multi-skill" | "monorepo" | "unknown";
166
+ skillCount: number;
167
+ skillPaths: string[];
168
+ isMonorepo: boolean;
169
+ hasExistingHooks: boolean;
170
+ suggestedTemplate: "single-skill" | "multi-skill" | null;
171
+ }
172
+
173
+ /**
174
+ * Recursively find SKILL.md files under a root directory,
175
+ * skipping ignored directories (node_modules, .git, etc.).
176
+ */
177
+ function findSkillFiles(dir: string, maxDepth = 8, depth = 0): string[] {
178
+ if (depth > maxDepth) return [];
179
+ if (!existsSync(dir)) return [];
180
+
181
+ const results: string[] = [];
182
+
183
+ try {
184
+ const entries = readdirSync(dir, { withFileTypes: true });
185
+ for (const entry of entries) {
186
+ if (entry.isDirectory()) {
187
+ if (IGNORE_DIRS.has(entry.name)) continue;
188
+ results.push(...findSkillFiles(join(dir, entry.name), maxDepth, depth + 1));
189
+ } else if (entry.name === "SKILL.md") {
190
+ results.push(join(dir, entry.name));
191
+ }
192
+ }
193
+ } catch {
194
+ // Permission errors, etc. — skip
195
+ }
196
+
197
+ return results;
198
+ }
199
+
200
+ /**
201
+ * Detect whether the root directory is a monorepo by checking for
202
+ * package.json workspaces or pnpm-workspace.yaml.
203
+ */
204
+ function detectMonorepo(rootDir: string): boolean {
205
+ // Check package.json workspaces field
206
+ const pkgPath = join(rootDir, "package.json");
207
+ if (existsSync(pkgPath)) {
208
+ try {
209
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
210
+ if (pkg.workspaces) return true;
211
+ } catch {
212
+ // invalid JSON — skip
213
+ }
214
+ }
215
+
216
+ // Check pnpm-workspace.yaml
217
+ if (existsSync(join(rootDir, "pnpm-workspace.yaml"))) return true;
218
+
219
+ // Check lerna.json
220
+ if (existsSync(join(rootDir, "lerna.json"))) return true;
221
+
222
+ return false;
223
+ }
224
+
225
+ /**
226
+ * Detect whether the project has existing selftune hooks configured.
227
+ */
228
+ function detectExistingHooks(rootDir: string): boolean {
229
+ const hooksDir = join(rootDir, "cli", "selftune", "hooks");
230
+ if (!existsSync(hooksDir)) return false;
231
+
232
+ try {
233
+ const entries = readdirSync(hooksDir);
234
+ return entries.some((e) => e.endsWith(".ts") || e.endsWith(".js"));
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Scan a project root and detect the workspace type, skill layout,
242
+ * and suggest an appropriate template.
243
+ */
244
+ export function detectWorkspaceType(rootDir: string): WorkspaceInfo {
245
+ const skillPaths = findSkillFiles(rootDir);
246
+ const isMonorepo = detectMonorepo(rootDir);
247
+ const hasExistingHooks = detectExistingHooks(rootDir);
248
+ const skillCount = skillPaths.length;
249
+
250
+ let type: WorkspaceInfo["type"];
251
+ let suggestedTemplate: WorkspaceInfo["suggestedTemplate"];
252
+
253
+ if (isMonorepo) {
254
+ type = "monorepo";
255
+ suggestedTemplate = "multi-skill";
256
+ } else if (skillCount === 0) {
257
+ type = "unknown";
258
+ suggestedTemplate = null;
259
+ } else if (skillCount === 1) {
260
+ type = "single-skill";
261
+ suggestedTemplate = "single-skill";
262
+ } else {
263
+ type = "multi-skill";
264
+ suggestedTemplate = "multi-skill";
265
+ }
266
+
267
+ return {
268
+ type,
269
+ skillCount,
270
+ skillPaths,
271
+ isMonorepo,
272
+ hasExistingHooks,
273
+ suggestedTemplate,
274
+ };
275
+ }
276
+
150
277
  // ---------------------------------------------------------------------------
151
278
  // Init options (for testability)
152
279
  // ---------------------------------------------------------------------------
@@ -265,11 +392,31 @@ export async function cliMain(): Promise<void> {
265
392
 
266
393
  console.log(JSON.stringify(config, null, 2));
267
394
 
395
+ // Detect workspace type and report
396
+ const workspace = detectWorkspaceType(process.cwd());
397
+ console.log(
398
+ JSON.stringify({
399
+ level: "info",
400
+ code: "workspace_detected",
401
+ type: workspace.type,
402
+ skills: workspace.skillCount,
403
+ monorepo: workspace.isMonorepo,
404
+ suggestedTemplate: workspace.suggestedTemplate
405
+ ? `templates/${workspace.suggestedTemplate}-settings.json`
406
+ : null,
407
+ }),
408
+ );
409
+
268
410
  // Run doctor as post-check
269
411
  const { doctor } = await import("./observability.js");
270
412
  const doctorResult = doctor();
271
- console.error(
272
- `\n[doctor] ${doctorResult.summary.pass}/${doctorResult.summary.total} checks pass`,
413
+ console.log(
414
+ JSON.stringify({
415
+ level: "info",
416
+ code: "doctor_result",
417
+ pass: doctorResult.summary.pass,
418
+ total: doctorResult.summary.total,
419
+ }),
273
420
  );
274
421
  }
275
422