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.
Files changed (122) hide show
  1. package/.claude/agents/diagnosis-analyst.md +20 -10
  2. package/.claude/agents/evolution-reviewer.md +14 -1
  3. package/.claude/agents/integration-guide.md +18 -6
  4. package/.claude/agents/pattern-analyst.md +18 -5
  5. package/CHANGELOG.md +12 -4
  6. package/README.md +43 -35
  7. package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  8. package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  9. package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  10. package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
  11. package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
  12. package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
  13. package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
  14. package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
  15. package/apps/local-dashboard/dist/favicon.png +0 -0
  16. package/apps/local-dashboard/dist/index.html +17 -0
  17. package/apps/local-dashboard/dist/logo.png +0 -0
  18. package/apps/local-dashboard/dist/logo.svg +9 -0
  19. package/cli/selftune/badge/badge-data.ts +1 -1
  20. package/cli/selftune/badge/badge.ts +4 -8
  21. package/cli/selftune/canonical-export.ts +183 -0
  22. package/cli/selftune/constants.ts +28 -0
  23. package/cli/selftune/contribute/contribute.ts +1 -1
  24. package/cli/selftune/cron/setup.ts +17 -17
  25. package/cli/selftune/dashboard-contract.ts +202 -0
  26. package/cli/selftune/dashboard-server.ts +653 -186
  27. package/cli/selftune/dashboard.ts +41 -176
  28. package/cli/selftune/eval/baseline.ts +5 -4
  29. package/cli/selftune/eval/composability-v2.ts +273 -0
  30. package/cli/selftune/eval/hooks-to-evals.ts +34 -15
  31. package/cli/selftune/eval/unit-test-cli.ts +1 -1
  32. package/cli/selftune/evolution/evidence.ts +26 -0
  33. package/cli/selftune/evolution/evolve-body.ts +105 -11
  34. package/cli/selftune/evolution/evolve.ts +371 -25
  35. package/cli/selftune/evolution/extract-patterns.ts +87 -29
  36. package/cli/selftune/evolution/rollback.ts +2 -2
  37. package/cli/selftune/grading/auto-grade.ts +200 -0
  38. package/cli/selftune/grading/grade-session.ts +448 -97
  39. package/cli/selftune/grading/results.ts +42 -0
  40. package/cli/selftune/hooks/prompt-log.ts +172 -2
  41. package/cli/selftune/hooks/session-stop.ts +123 -3
  42. package/cli/selftune/hooks/skill-eval.ts +119 -3
  43. package/cli/selftune/index.ts +395 -116
  44. package/cli/selftune/ingestors/claude-replay.ts +140 -114
  45. package/cli/selftune/ingestors/codex-rollout.ts +345 -46
  46. package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
  47. package/cli/selftune/ingestors/openclaw-ingest.ts +141 -8
  48. package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
  49. package/cli/selftune/init.ts +227 -14
  50. package/cli/selftune/last.ts +14 -5
  51. package/cli/selftune/localdb/db.ts +63 -0
  52. package/cli/selftune/localdb/materialize.ts +428 -0
  53. package/cli/selftune/localdb/queries.ts +376 -0
  54. package/cli/selftune/localdb/schema.ts +204 -0
  55. package/cli/selftune/monitoring/watch.ts +66 -15
  56. package/cli/selftune/normalization.ts +682 -0
  57. package/cli/selftune/observability.ts +19 -44
  58. package/cli/selftune/orchestrate.ts +1073 -0
  59. package/cli/selftune/quickstart.ts +203 -0
  60. package/cli/selftune/repair/skill-usage.ts +576 -0
  61. package/cli/selftune/schedule.ts +561 -0
  62. package/cli/selftune/status.ts +48 -26
  63. package/cli/selftune/sync.ts +627 -0
  64. package/cli/selftune/types.ts +148 -0
  65. package/cli/selftune/utils/canonical-log.ts +45 -0
  66. package/cli/selftune/utils/hooks.ts +41 -0
  67. package/cli/selftune/utils/html.ts +27 -0
  68. package/cli/selftune/utils/llm-call.ts +78 -20
  69. package/cli/selftune/utils/math.ts +10 -0
  70. package/cli/selftune/utils/query-filter.ts +139 -0
  71. package/cli/selftune/utils/skill-discovery.ts +340 -0
  72. package/cli/selftune/utils/skill-log.ts +68 -0
  73. package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
  74. package/cli/selftune/utils/transcript.ts +272 -26
  75. package/cli/selftune/workflows/discover.ts +254 -0
  76. package/cli/selftune/workflows/skill-md-writer.ts +288 -0
  77. package/cli/selftune/workflows/workflows.ts +188 -0
  78. package/package.json +21 -8
  79. package/packages/telemetry-contract/README.md +11 -0
  80. package/packages/telemetry-contract/fixtures/golden.json +87 -0
  81. package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
  82. package/packages/telemetry-contract/index.ts +1 -0
  83. package/packages/telemetry-contract/package.json +19 -0
  84. package/packages/telemetry-contract/src/index.ts +2 -0
  85. package/packages/telemetry-contract/src/types.ts +163 -0
  86. package/packages/telemetry-contract/src/validators.ts +109 -0
  87. package/skill/SKILL.md +84 -53
  88. package/skill/Workflows/AutoActivation.md +17 -16
  89. package/skill/Workflows/Badge.md +6 -0
  90. package/skill/Workflows/Baseline.md +46 -23
  91. package/skill/Workflows/Composability.md +12 -5
  92. package/skill/Workflows/Contribute.md +17 -14
  93. package/skill/Workflows/Cron.md +56 -79
  94. package/skill/Workflows/Dashboard.md +45 -34
  95. package/skill/Workflows/Doctor.md +30 -17
  96. package/skill/Workflows/Evals.md +64 -40
  97. package/skill/Workflows/EvolutionMemory.md +2 -0
  98. package/skill/Workflows/Evolve.md +102 -47
  99. package/skill/Workflows/EvolveBody.md +6 -6
  100. package/skill/Workflows/Grade.md +36 -31
  101. package/skill/Workflows/ImportSkillsBench.md +11 -5
  102. package/skill/Workflows/Ingest.md +43 -36
  103. package/skill/Workflows/Initialize.md +44 -30
  104. package/skill/Workflows/Orchestrate.md +139 -0
  105. package/skill/Workflows/Replay.md +39 -18
  106. package/skill/Workflows/Rollback.md +3 -3
  107. package/skill/Workflows/Schedule.md +61 -0
  108. package/skill/Workflows/Sync.md +88 -0
  109. package/skill/Workflows/UnitTest.md +34 -22
  110. package/skill/Workflows/Watch.md +14 -4
  111. package/skill/Workflows/Workflows.md +129 -0
  112. package/skill/assets/activation-rules-default.json +26 -0
  113. package/skill/assets/multi-skill-settings.json +63 -0
  114. package/skill/assets/single-skill-settings.json +57 -0
  115. package/skill/references/invocation-taxonomy.md +2 -2
  116. package/skill/references/logs.md +164 -2
  117. package/skill/references/setup-patterns.md +65 -0
  118. package/skill/references/version-history.md +40 -0
  119. package/skill/settings_snippet.json +1 -1
  120. package/templates/multi-skill-settings.json +7 -7
  121. package/templates/single-skill-settings.json +6 -6
  122. package/dashboard/index.html +0 -1680
@@ -25,8 +25,24 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
26
  import { basename, join } from "node:path";
27
27
  import { parseArgs } from "node:util";
28
- import { QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "../constants.js";
29
- import type { QueryLogRecord, SkillUsageRecord } from "../types.js";
28
+ import { CANONICAL_LOG, QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "../constants.js";
29
+ import {
30
+ appendCanonicalRecords,
31
+ buildCanonicalExecutionFact,
32
+ buildCanonicalPrompt,
33
+ buildCanonicalSession,
34
+ buildCanonicalSkillInvocation,
35
+ type CanonicalBaseInput,
36
+ deriveInvocationMode,
37
+ derivePromptId,
38
+ deriveSkillInvocationId,
39
+ } from "../normalization.js";
40
+ import type {
41
+ CanonicalRecord,
42
+ QueryLogRecord,
43
+ SessionTelemetryRecord,
44
+ SkillUsageRecord,
45
+ } from "../types.js";
30
46
  import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
31
47
 
32
48
  const XDG_DATA_HOME = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
@@ -48,6 +64,26 @@ const OPENCODE_SKILLS_DIRS = [
48
64
  join(homedir(), ".config", "opencode", "skills"),
49
65
  ];
50
66
 
67
+ interface TriggeredSkillDetection {
68
+ skill_name: string;
69
+ has_skill_md_read: boolean;
70
+ }
71
+
72
+ function escapeRegExp(value: string): string {
73
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
74
+ }
75
+
76
+ function containsWholeSkillMention(text: string, skillName: string): boolean {
77
+ const trimmedSkillName = skillName.trim();
78
+ if (!text || !trimmedSkillName) return false;
79
+
80
+ const pattern = new RegExp(
81
+ `(^|[^A-Za-z0-9_])${escapeRegExp(trimmedSkillName)}([^A-Za-z0-9_]|$)`,
82
+ "i",
83
+ );
84
+ return pattern.test(text);
85
+ }
86
+
51
87
  /** Return skill names from OpenCode skill directories. */
52
88
  export function findSkillNames(dirs: string[] = OPENCODE_SKILLS_DIRS): Set<string> {
53
89
  const names = new Set<string>();
@@ -79,9 +115,12 @@ export interface ParsedSession {
79
115
  total_tool_calls: number;
80
116
  bash_commands: string[];
81
117
  skills_triggered: string[];
118
+ skill_detections?: TriggeredSkillDetection[];
82
119
  assistant_turns: number;
83
120
  errors_encountered: number;
84
121
  transcript_chars: number;
122
+ /** True when local session JSON is metadata-only (no embedded messages). */
123
+ is_metadata_only?: boolean;
85
124
  }
86
125
 
87
126
  /** Return a human-readable schema summary for --show-schema. */
@@ -202,10 +241,24 @@ export function readSessionsFromSqlite(
202
241
  let firstUserQuery = "";
203
242
  const toolCalls: Record<string, number> = {};
204
243
  const bashCommands: string[] = [];
205
- const skillsTriggered: string[] = [];
244
+ const skillDetections = new Map<string, TriggeredSkillDetection>();
206
245
  let errors = 0;
207
246
  let assistantTurns = 0;
208
247
 
248
+ const noteSkillDetection = (skillName: string, hasSkillMdRead: boolean): void => {
249
+ const normalizedSkillName = skillName.trim();
250
+ if (!normalizedSkillName) return;
251
+ const existing = skillDetections.get(normalizedSkillName);
252
+ if (existing) {
253
+ existing.has_skill_md_read = existing.has_skill_md_read || hasSkillMdRead;
254
+ return;
255
+ }
256
+ skillDetections.set(normalizedSkillName, {
257
+ skill_name: normalizedSkillName,
258
+ has_skill_md_read: hasSkillMdRead,
259
+ });
260
+ };
261
+
209
262
  for (const msg of msgRows) {
210
263
  const role = (msg.role as string) ?? "";
211
264
  const blocks = normalizeContent(msg.content ?? "[]");
@@ -251,9 +304,7 @@ export function readSessionsFromSqlite(
251
304
  const filePath = (inp.file_path as string) ?? (inp.path as string) ?? "";
252
305
  if (basename(filePath).toUpperCase() === "SKILL.MD") {
253
306
  const skillName = basename(join(filePath, ".."));
254
- if (!skillsTriggered.includes(skillName)) {
255
- skillsTriggered.push(skillName);
256
- }
307
+ noteSkillDetection(skillName, true);
257
308
  }
258
309
  }
259
310
  }
@@ -271,8 +322,8 @@ export function readSessionsFromSqlite(
271
322
  // Check text content for skill name mentions
272
323
  const textContent = (block.text as string) ?? "";
273
324
  for (const skillName of skillNames) {
274
- if (textContent.includes(skillName) && !skillsTriggered.includes(skillName)) {
275
- skillsTriggered.push(skillName);
325
+ if (containsWholeSkillMention(textContent, skillName)) {
326
+ noteSkillDetection(skillName, false);
276
327
  }
277
328
  }
278
329
  }
@@ -299,7 +350,8 @@ export function readSessionsFromSqlite(
299
350
  tool_calls: toolCalls,
300
351
  total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
301
352
  bash_commands: bashCommands,
302
- skills_triggered: skillsTriggered,
353
+ skills_triggered: [...skillDetections.values()].map((entry) => entry.skill_name),
354
+ skill_detections: [...skillDetections.values()],
303
355
  assistant_turns: assistantTurns,
304
356
  errors_encountered: errors,
305
357
  transcript_chars: 0,
@@ -349,13 +401,30 @@ export function readSessionsFromJsonFiles(
349
401
  const timestamp = new Date(created * 1000).toISOString();
350
402
  const messages = (data.messages as Array<Record<string, unknown>>) ?? [];
351
403
 
404
+ // Detect metadata-only session files (no message bodies)
405
+ const isMetadataOnly = messages.length === 0;
406
+
352
407
  let firstUserQuery = "";
353
408
  const toolCalls: Record<string, number> = {};
354
409
  const bashCommands: string[] = [];
355
- const skillsTriggered: string[] = [];
410
+ const skillDetections = new Map<string, TriggeredSkillDetection>();
356
411
  let errors = 0;
357
412
  let turns = 0;
358
413
 
414
+ const noteSkillDetection = (skillName: string, hasSkillMdRead: boolean): void => {
415
+ const normalizedSkillName = skillName.trim();
416
+ if (!normalizedSkillName) return;
417
+ const existing = skillDetections.get(normalizedSkillName);
418
+ if (existing) {
419
+ existing.has_skill_md_read = existing.has_skill_md_read || hasSkillMdRead;
420
+ return;
421
+ }
422
+ skillDetections.set(normalizedSkillName, {
423
+ skill_name: normalizedSkillName,
424
+ has_skill_md_read: hasSkillMdRead,
425
+ });
426
+ };
427
+
359
428
  for (const msg of messages) {
360
429
  const role = (msg.role as string) ?? "";
361
430
  const blocks = normalizeContent(msg.content ?? []);
@@ -385,17 +454,15 @@ export function readSessionsFromJsonFiles(
385
454
  const fp = (inp.file_path as string) ?? "";
386
455
  if (basename(fp).toUpperCase() === "SKILL.MD") {
387
456
  const sn = basename(join(fp, ".."));
388
- if (!skillsTriggered.includes(sn)) {
389
- skillsTriggered.push(sn);
390
- }
457
+ noteSkillDetection(sn, true);
391
458
  }
392
459
  }
393
460
  }
394
461
 
395
462
  const text = (block.text as string) ?? "";
396
463
  for (const skillName of skillNames) {
397
- if (text.includes(skillName) && !skillsTriggered.includes(skillName)) {
398
- skillsTriggered.push(skillName);
464
+ if (containsWholeSkillMention(text, skillName)) {
465
+ noteSkillDetection(skillName, false);
399
466
  }
400
467
  }
401
468
  }
@@ -422,10 +489,12 @@ export function readSessionsFromJsonFiles(
422
489
  tool_calls: toolCalls,
423
490
  total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
424
491
  bash_commands: bashCommands,
425
- skills_triggered: skillsTriggered,
492
+ skills_triggered: [...skillDetections.values()].map((entry) => entry.skill_name),
493
+ skill_detections: [...skillDetections.values()],
426
494
  assistant_turns: turns,
427
495
  errors_encountered: errors,
428
496
  transcript_chars: statSync(filePath).size,
497
+ is_metadata_only: isMetadataOnly,
429
498
  });
430
499
  }
431
500
 
@@ -439,6 +508,7 @@ export function writeSession(
439
508
  queryLogPath: string = QUERY_LOG,
440
509
  telemetryLogPath: string = TELEMETRY_LOG,
441
510
  skillLogPath: string = SKILL_LOG,
511
+ canonicalLogPath: string = CANONICAL_LOG,
442
512
  ): void {
443
513
  const { query: prompt, session_id: sessionId, skills_triggered: skills } = session;
444
514
 
@@ -460,7 +530,21 @@ export function writeSession(
460
530
  appendJsonl(queryLogPath, queryRecord, "all_queries");
461
531
  }
462
532
 
463
- const { query: _q, ...telemetry } = session;
533
+ const telemetry: SessionTelemetryRecord = {
534
+ timestamp: session.timestamp,
535
+ session_id: session.session_id,
536
+ cwd: session.cwd,
537
+ transcript_path: session.transcript_path,
538
+ tool_calls: session.tool_calls,
539
+ total_tool_calls: session.total_tool_calls,
540
+ bash_commands: session.bash_commands,
541
+ skills_triggered: session.skills_triggered,
542
+ assistant_turns: session.assistant_turns,
543
+ errors_encountered: session.errors_encountered,
544
+ transcript_chars: session.transcript_chars,
545
+ last_user_query: session.last_user_query,
546
+ source: session.source,
547
+ };
464
548
  appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
465
549
 
466
550
  for (const skillName of skills) {
@@ -475,6 +559,98 @@ export function writeSession(
475
559
  };
476
560
  appendJsonl(skillLogPath, skillRecord, "skill_usage");
477
561
  }
562
+
563
+ // --- Canonical normalization records (additive) ---
564
+ const canonicalRecords = buildCanonicalRecordsFromOpenCode(session);
565
+ appendCanonicalRecords(canonicalRecords, canonicalLogPath);
566
+ }
567
+
568
+ /** Build canonical records from a parsed OpenCode session. */
569
+ export function buildCanonicalRecordsFromOpenCode(session: ParsedSession): CanonicalRecord[] {
570
+ const records: CanonicalRecord[] = [];
571
+ const sourceKind = session.is_metadata_only ? ("replayed" as const) : ("replayed" as const);
572
+ const baseInput: CanonicalBaseInput = {
573
+ platform: "opencode",
574
+ capture_mode: "batch_ingest",
575
+ source_session_kind: sourceKind,
576
+ session_id: session.session_id,
577
+ raw_source_ref: {
578
+ path: session.transcript_path,
579
+ event_type: session.source,
580
+ metadata: session.is_metadata_only ? { metadata_only: true } : undefined,
581
+ },
582
+ };
583
+
584
+ records.push(
585
+ buildCanonicalSession({
586
+ ...baseInput,
587
+ started_at: session.timestamp,
588
+ workspace_path: session.cwd || undefined,
589
+ }),
590
+ );
591
+
592
+ const promptEmitted = Boolean(
593
+ session.query && session.query.length >= 4 && !session.is_metadata_only,
594
+ );
595
+ const promptId = promptEmitted ? derivePromptId(session.session_id, 0) : undefined;
596
+
597
+ if (promptId) {
598
+ records.push(
599
+ buildCanonicalPrompt({
600
+ ...baseInput,
601
+ prompt_id: promptId,
602
+ occurred_at: session.timestamp,
603
+ prompt_text: session.query,
604
+ prompt_index: 0,
605
+ }),
606
+ );
607
+ }
608
+
609
+ const skillDetections =
610
+ session.skill_detections ??
611
+ session.skills_triggered.map((skillName) => ({
612
+ skill_name: skillName,
613
+ has_skill_md_read: false,
614
+ }));
615
+
616
+ for (let i = 0; i < skillDetections.length; i++) {
617
+ const detection = skillDetections[i];
618
+ const skillName = detection.skill_name;
619
+ const { invocation_mode, confidence } = deriveInvocationMode({
620
+ has_skill_md_read: detection.has_skill_md_read,
621
+ is_text_mention_only: !detection.has_skill_md_read,
622
+ });
623
+ records.push(
624
+ buildCanonicalSkillInvocation({
625
+ ...baseInput,
626
+ skill_invocation_id: deriveSkillInvocationId(session.session_id, skillName, i),
627
+ occurred_at: session.timestamp,
628
+ matched_prompt_id: promptId,
629
+ skill_name: skillName,
630
+ skill_path: `(opencode:${skillName})`,
631
+ invocation_mode,
632
+ triggered: true,
633
+ confidence,
634
+ }),
635
+ );
636
+ }
637
+
638
+ if (!session.is_metadata_only) {
639
+ records.push(
640
+ buildCanonicalExecutionFact({
641
+ ...baseInput,
642
+ occurred_at: session.timestamp,
643
+ prompt_id: promptId,
644
+ tool_calls_json: session.tool_calls,
645
+ total_tool_calls: session.total_tool_calls,
646
+ bash_commands_redacted: session.bash_commands,
647
+ assistant_turns: session.assistant_turns,
648
+ errors_encountered: session.errors_encountered,
649
+ }),
650
+ );
651
+ }
652
+
653
+ return records;
478
654
  }
479
655
 
480
656
  // --- CLI main ---
@@ -8,16 +8,25 @@
8
8
  *
9
9
  * Usage:
10
10
  * selftune init [--agent <type>] [--cli-path <path>] [--force]
11
+ * selftune init --enable-autonomy [--schedule-format cron|launchd|systemd]
11
12
  */
12
13
 
13
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import {
15
+ copyFileSync,
16
+ existsSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ writeFileSync,
21
+ } from "node:fs";
14
22
  import { homedir } from "node:os";
15
23
  import { dirname, join, resolve } from "node:path";
16
24
  import { fileURLToPath } from "node:url";
17
25
  import { parseArgs } from "node:util";
18
26
 
19
- import { SELFTUNE_CONFIG_DIR, SELFTUNE_CONFIG_PATH } from "./constants.js";
27
+ import { CLAUDE_CODE_HOOK_KEYS, SELFTUNE_CONFIG_DIR, SELFTUNE_CONFIG_PATH } from "./constants.js";
20
28
  import type { SelftuneConfig } from "./types.js";
29
+ import { hookKeyHasSelftuneEntry } from "./utils/hooks.js";
21
30
  import { detectAgent } from "./utils/llm-call.js";
22
31
 
23
32
  // ---------------------------------------------------------------------------
@@ -124,8 +133,6 @@ export function determineLlmMode(agentCli: string | null): {
124
133
  // Hook detection (Claude Code only)
125
134
  // ---------------------------------------------------------------------------
126
135
 
127
- const REQUIRED_HOOK_KEYS = ["prompt-submit", "post-tool-use", "session-stop"] as const;
128
-
129
136
  /**
130
137
  * Check if the selftune hooks are configured in Claude Code settings.
131
138
  */
@@ -138,15 +145,10 @@ export function checkClaudeCodeHooks(settingsPath: string): boolean {
138
145
  const hooks = settings?.hooks;
139
146
  if (!hooks || typeof hooks !== "object") return false;
140
147
 
141
- for (const key of REQUIRED_HOOK_KEYS) {
142
- const entries = hooks[key];
143
- if (!Array.isArray(entries) || entries.length === 0) return false;
144
- // Check that at least one entry references selftune
145
- const hasSelftune = entries.some(
146
- (e: { command?: string }) =>
147
- typeof e.command === "string" && e.command.includes("selftune"),
148
- );
149
- if (!hasSelftune) return false;
148
+ for (const key of CLAUDE_CODE_HOOK_KEYS) {
149
+ if (!hookKeyHasSelftuneEntry(hooks, key)) {
150
+ return false;
151
+ }
150
152
  }
151
153
 
152
154
  return true;
@@ -155,6 +157,155 @@ export function checkClaudeCodeHooks(settingsPath: string): boolean {
155
157
  }
156
158
  }
157
159
 
160
+ // ---------------------------------------------------------------------------
161
+ // Hook installation (Claude Code only)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /** Bundled settings snippet (ships with the npm package). */
165
+ const SETTINGS_SNIPPET_PATH = resolve(
166
+ dirname(import.meta.path),
167
+ "..",
168
+ "..",
169
+ "skill",
170
+ "settings_snippet.json",
171
+ );
172
+
173
+ /**
174
+ * Install selftune hooks into ~/.claude/settings.json by merging entries
175
+ * from the bundled settings_snippet.json.
176
+ *
177
+ * - Creates settings.json if it does not exist
178
+ * - Creates the hooks section if it does not exist
179
+ * - Only adds hook entries for keys that don't already have a selftune entry
180
+ * - Never overwrites existing user hooks
181
+ *
182
+ * Returns the list of hook keys that were added.
183
+ */
184
+ export function installClaudeCodeHooks(options?: {
185
+ settingsPath?: string;
186
+ snippetPath?: string;
187
+ cliPath?: string;
188
+ }): string[] {
189
+ const settingsPath = options?.settingsPath ?? join(homedir(), ".claude", "settings.json");
190
+ const snippetPath = options?.snippetPath ?? SETTINGS_SNIPPET_PATH;
191
+
192
+ // Read the snippet
193
+ if (!existsSync(snippetPath)) {
194
+ console.error(`[WARN] Hook snippet not found at ${snippetPath}, skipping hook installation`);
195
+ return [];
196
+ }
197
+
198
+ let snippet: Record<string, unknown>;
199
+ try {
200
+ snippet = JSON.parse(readFileSync(snippetPath, "utf-8"));
201
+ } catch {
202
+ console.error(`[WARN] Failed to parse hook snippet at ${snippetPath}`);
203
+ return [];
204
+ }
205
+
206
+ const snippetHooks = snippet.hooks as Record<string, unknown[]> | undefined;
207
+ if (!snippetHooks || typeof snippetHooks !== "object") {
208
+ console.error("[WARN] Hook snippet has no 'hooks' section");
209
+ return [];
210
+ }
211
+
212
+ // Read existing settings (or start with empty object)
213
+ let settings: Record<string, unknown> = {};
214
+ if (existsSync(settingsPath)) {
215
+ try {
216
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
217
+ } catch {
218
+ console.error(`[WARN] Failed to parse ${settingsPath}, starting with empty settings`);
219
+ settings = {};
220
+ }
221
+ }
222
+
223
+ // Ensure hooks section exists
224
+ if (!settings.hooks || typeof settings.hooks !== "object") {
225
+ settings.hooks = {};
226
+ }
227
+ const existingHooks = settings.hooks as Record<string, unknown[]>;
228
+
229
+ // Resolve the CLI hooks directory for path substitution
230
+ const cliPath = options?.cliPath;
231
+ const hooksDir = cliPath ? `${dirname(cliPath)}/hooks` : null;
232
+
233
+ const addedKeys: string[] = [];
234
+
235
+ for (const key of Object.keys(snippetHooks)) {
236
+ // Skip if this key already has a selftune entry
237
+ if (hookKeyHasSelftuneEntry(existingHooks, key)) {
238
+ continue;
239
+ }
240
+
241
+ // Get the snippet entries for this key, replacing /PATH/TO/ with actual path
242
+ let entries = snippetHooks[key];
243
+ if (hooksDir) {
244
+ // Deep clone and substitute paths
245
+ const raw = JSON.stringify(entries).replace(/\/PATH\/TO\/cli\/selftune\/hooks/g, hooksDir);
246
+ entries = JSON.parse(raw);
247
+ }
248
+
249
+ // Merge: append to existing array or create new one
250
+ if (Array.isArray(existingHooks[key])) {
251
+ existingHooks[key] = [...existingHooks[key], ...entries];
252
+ } else {
253
+ existingHooks[key] = entries;
254
+ }
255
+
256
+ addedKeys.push(key);
257
+ }
258
+
259
+ if (addedKeys.length > 0) {
260
+ // Ensure ~/.claude/ directory exists
261
+ mkdirSync(dirname(settingsPath), { recursive: true });
262
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf-8");
263
+ }
264
+
265
+ return addedKeys;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Agent file installation
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /** Bundled agent files directory (ships with the npm package). */
273
+ const BUNDLED_AGENTS_DIR = resolve(dirname(import.meta.path), "..", "..", ".claude", "agents");
274
+
275
+ /**
276
+ * Copy bundled agent markdown files to ~/.claude/agents/.
277
+ * Returns a list of file names that were copied (skips files that already exist
278
+ * unless `force` is true).
279
+ */
280
+ export function installAgentFiles(options?: { homeDir?: string; force?: boolean }): string[] {
281
+ const home = options?.homeDir ?? homedir();
282
+ const force = options?.force ?? false;
283
+ const targetDir = join(home, ".claude", "agents");
284
+
285
+ if (!existsSync(BUNDLED_AGENTS_DIR)) return [];
286
+
287
+ let sourceFiles: string[];
288
+ try {
289
+ sourceFiles = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
290
+ } catch {
291
+ return [];
292
+ }
293
+
294
+ if (sourceFiles.length === 0) return [];
295
+
296
+ mkdirSync(targetDir, { recursive: true });
297
+
298
+ const copied: string[] = [];
299
+ for (const file of sourceFiles) {
300
+ const dest = join(targetDir, file);
301
+ if (!force && existsSync(dest)) continue;
302
+ copyFileSync(join(BUNDLED_AGENTS_DIR, file), dest);
303
+ copied.push(file);
304
+ }
305
+
306
+ return copied;
307
+ }
308
+
158
309
  // ---------------------------------------------------------------------------
159
310
  // Workspace type detection
160
311
  // ---------------------------------------------------------------------------
@@ -346,6 +497,34 @@ export function runInit(opts: InitOptions): SelftuneConfig {
346
497
  mkdirSync(configDir, { recursive: true });
347
498
  writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
348
499
 
500
+ // Install agent files to ~/.claude/agents/
501
+ const copiedAgents = installAgentFiles({ homeDir: home, force });
502
+ if (copiedAgents.length > 0) {
503
+ console.error(`[INFO] Installed agent files: ${copiedAgents.join(", ")}`);
504
+ }
505
+
506
+ // Auto-install hooks into ~/.claude/settings.json (Claude Code only)
507
+ if (agentType === "claude_code") {
508
+ const addedHookKeys = installClaudeCodeHooks({
509
+ settingsPath,
510
+ cliPath,
511
+ });
512
+ if (addedHookKeys.length > 0) {
513
+ config.hooks_installed = true;
514
+ // Re-write config with updated hooks_installed flag
515
+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
516
+ console.error(
517
+ `[INFO] Installed ${addedHookKeys.length} selftune hook(s) into ${settingsPath}: ${addedHookKeys.join(", ")}`,
518
+ );
519
+ } else if (!config.hooks_installed) {
520
+ // Re-check in case hooks were already present
521
+ config.hooks_installed = checkClaudeCodeHooks(settingsPath);
522
+ if (config.hooks_installed) {
523
+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
524
+ }
525
+ }
526
+ }
527
+
349
528
  return config;
350
529
  }
351
530
 
@@ -359,6 +538,8 @@ export async function cliMain(): Promise<void> {
359
538
  agent: { type: "string" },
360
539
  "cli-path": { type: "string" },
361
540
  force: { type: "boolean", default: false },
541
+ "enable-autonomy": { type: "boolean", default: false },
542
+ "schedule-format": { type: "string" },
362
543
  },
363
544
  strict: true,
364
545
  });
@@ -366,9 +547,10 @@ export async function cliMain(): Promise<void> {
366
547
  const configDir = SELFTUNE_CONFIG_DIR;
367
548
  const configPath = SELFTUNE_CONFIG_PATH;
368
549
  const force = values.force ?? false;
550
+ const enableAutonomy = values["enable-autonomy"] ?? false;
369
551
 
370
552
  // Check for existing config without force
371
- if (!force && existsSync(configPath)) {
553
+ if (!force && !enableAutonomy && existsSync(configPath)) {
372
554
  try {
373
555
  const raw = readFileSync(configPath, "utf-8");
374
556
  const existing = JSON.parse(raw) as SelftuneConfig;
@@ -418,6 +600,37 @@ export async function cliMain(): Promise<void> {
418
600
  total: doctorResult.summary.total,
419
601
  }),
420
602
  );
603
+
604
+ if (enableAutonomy) {
605
+ try {
606
+ const { installSchedule } = await import("./schedule.js");
607
+ const scheduleResult = installSchedule({
608
+ format: values["schedule-format"],
609
+ });
610
+
611
+ if (!scheduleResult.activated) {
612
+ console.error(
613
+ "Failed to activate the autonomous scheduler. Re-run with --schedule-format or use `selftune schedule --install --dry-run` to inspect the generated artifacts first.",
614
+ );
615
+ process.exit(1);
616
+ }
617
+
618
+ console.log(
619
+ JSON.stringify({
620
+ level: "info",
621
+ code: "autonomy_enabled",
622
+ format: scheduleResult.format,
623
+ activated: scheduleResult.activated,
624
+ files: scheduleResult.artifacts.map((artifact) => artifact.path),
625
+ }),
626
+ );
627
+ } catch (err) {
628
+ console.error(
629
+ `Failed to enable autonomy: ${err instanceof Error ? err.message : String(err)}`,
630
+ );
631
+ process.exit(1);
632
+ }
633
+ }
421
634
  }
422
635
 
423
636
  // Guard: only run when invoked directly
@@ -4,9 +4,14 @@
4
4
  * Lightweight, no LLM calls.
5
5
  */
6
6
 
7
- import { QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "./constants.js";
7
+ import { QUERY_LOG, TELEMETRY_LOG } from "./constants.js";
8
8
  import type { QueryLogRecord, SessionTelemetryRecord, SkillUsageRecord } from "./types.js";
9
9
  import { readJsonl } from "./utils/jsonl.js";
10
+ import {
11
+ filterActionableQueryRecords,
12
+ filterActionableSkillUsageRecords,
13
+ } from "./utils/query-filter.js";
14
+ import { readEffectiveSkillUsageRecords } from "./utils/skill-log.js";
10
15
 
11
16
  // ---------------------------------------------------------------------------
12
17
  // Types
@@ -36,6 +41,8 @@ export function computeLastInsight(
36
41
  queryRecords: QueryLogRecord[],
37
42
  ): LastSessionInsight | null {
38
43
  if (telemetry.length === 0) return null;
44
+ const actionableSkillRecords = filterActionableSkillUsageRecords(skillRecords);
45
+ const actionableQueryRecords = filterActionableQueryRecords(queryRecords);
39
46
 
40
47
  // Find most recent telemetry record
41
48
  const sorted = [...telemetry].sort(
@@ -48,17 +55,19 @@ export function computeLastInsight(
48
55
  const triggeredSkillQueries = new Set<string>();
49
56
  const skillsTriggered = [
50
57
  ...new Set(
51
- skillRecords
58
+ actionableSkillRecords
52
59
  .filter((r) => r.session_id === sessionId && r.triggered)
53
60
  .map((r) => {
54
- triggeredSkillQueries.add(r.query.toLowerCase().trim());
61
+ if (typeof r.query === "string") {
62
+ triggeredSkillQueries.add(r.query.toLowerCase().trim());
63
+ }
55
64
  return r.skill_name;
56
65
  }),
57
66
  ),
58
67
  ];
59
68
 
60
69
  // Unmatched queries: session queries whose text does NOT appear in any triggered skill record
61
- const sessionQueries = queryRecords.filter((r) => r.session_id === sessionId);
70
+ const sessionQueries = actionableQueryRecords.filter((r) => r.session_id === sessionId);
62
71
  const unmatchedQueries = sessionQueries
63
72
  .filter((q) => !triggeredSkillQueries.has(q.query.toLowerCase().trim()))
64
73
  .map((q) => q.query);
@@ -124,7 +133,7 @@ export function formatInsight(insight: LastSessionInsight): string {
124
133
  /** CLI main: reads logs, prints insight. */
125
134
  export function cliMain(): void {
126
135
  const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
127
- const skillRecords = readJsonl<SkillUsageRecord>(SKILL_LOG);
136
+ const skillRecords = readEffectiveSkillUsageRecords();
128
137
  const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
129
138
 
130
139
  const insight = computeLastInsight(telemetry, skillRecords, queryRecords);