selftune 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/.claude/agents/diagnosis-analyst.md +156 -0
  2. package/.claude/agents/evolution-reviewer.md +180 -0
  3. package/.claude/agents/integration-guide.md +212 -0
  4. package/.claude/agents/pattern-analyst.md +160 -0
  5. package/CHANGELOG.md +46 -1
  6. package/README.md +105 -257
  7. package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  8. package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  9. package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  10. package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
  11. package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
  12. package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
  13. package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
  14. package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
  15. package/apps/local-dashboard/dist/favicon.png +0 -0
  16. package/apps/local-dashboard/dist/index.html +17 -0
  17. package/apps/local-dashboard/dist/logo.png +0 -0
  18. package/apps/local-dashboard/dist/logo.svg +9 -0
  19. package/assets/BeforeAfter.gif +0 -0
  20. package/assets/FeedbackLoop.gif +0 -0
  21. package/assets/logo.svg +9 -0
  22. package/assets/skill-health-badge.svg +20 -0
  23. package/cli/selftune/activation-rules.ts +171 -0
  24. package/cli/selftune/badge/badge-data.ts +108 -0
  25. package/cli/selftune/badge/badge-svg.ts +212 -0
  26. package/cli/selftune/badge/badge.ts +99 -0
  27. package/cli/selftune/canonical-export.ts +183 -0
  28. package/cli/selftune/constants.ts +103 -1
  29. package/cli/selftune/contribute/bundle.ts +314 -0
  30. package/cli/selftune/contribute/contribute.ts +214 -0
  31. package/cli/selftune/contribute/sanitize.ts +162 -0
  32. package/cli/selftune/cron/setup.ts +266 -0
  33. package/cli/selftune/dashboard-contract.ts +202 -0
  34. package/cli/selftune/dashboard-server.ts +1049 -0
  35. package/cli/selftune/dashboard.ts +43 -156
  36. package/cli/selftune/eval/baseline.ts +248 -0
  37. package/cli/selftune/eval/composability-v2.ts +273 -0
  38. package/cli/selftune/eval/composability.ts +117 -0
  39. package/cli/selftune/eval/generate-unit-tests.ts +143 -0
  40. package/cli/selftune/eval/hooks-to-evals.ts +101 -16
  41. package/cli/selftune/eval/import-skillsbench.ts +221 -0
  42. package/cli/selftune/eval/synthetic-evals.ts +172 -0
  43. package/cli/selftune/eval/unit-test-cli.ts +152 -0
  44. package/cli/selftune/eval/unit-test.ts +196 -0
  45. package/cli/selftune/evolution/deploy-proposal.ts +142 -1
  46. package/cli/selftune/evolution/evidence.ts +26 -0
  47. package/cli/selftune/evolution/evolve-body.ts +586 -0
  48. package/cli/selftune/evolution/evolve.ts +825 -116
  49. package/cli/selftune/evolution/extract-patterns.ts +105 -16
  50. package/cli/selftune/evolution/pareto.ts +314 -0
  51. package/cli/selftune/evolution/propose-body.ts +171 -0
  52. package/cli/selftune/evolution/propose-description.ts +100 -2
  53. package/cli/selftune/evolution/propose-routing.ts +166 -0
  54. package/cli/selftune/evolution/refine-body.ts +141 -0
  55. package/cli/selftune/evolution/rollback.ts +21 -4
  56. package/cli/selftune/evolution/validate-body.ts +254 -0
  57. package/cli/selftune/evolution/validate-proposal.ts +257 -35
  58. package/cli/selftune/evolution/validate-routing.ts +177 -0
  59. package/cli/selftune/grading/auto-grade.ts +200 -0
  60. package/cli/selftune/grading/grade-session.ts +513 -42
  61. package/cli/selftune/grading/pre-gates.ts +104 -0
  62. package/cli/selftune/grading/results.ts +42 -0
  63. package/cli/selftune/hooks/auto-activate.ts +185 -0
  64. package/cli/selftune/hooks/evolution-guard.ts +165 -0
  65. package/cli/selftune/hooks/prompt-log.ts +172 -2
  66. package/cli/selftune/hooks/session-stop.ts +123 -3
  67. package/cli/selftune/hooks/skill-change-guard.ts +112 -0
  68. package/cli/selftune/hooks/skill-eval.ts +119 -3
  69. package/cli/selftune/index.ts +415 -48
  70. package/cli/selftune/ingestors/claude-replay.ts +377 -0
  71. package/cli/selftune/ingestors/codex-rollout.ts +345 -46
  72. package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
  73. package/cli/selftune/ingestors/openclaw-ingest.ts +573 -0
  74. package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
  75. package/cli/selftune/init.ts +376 -16
  76. package/cli/selftune/last.ts +14 -5
  77. package/cli/selftune/localdb/db.ts +63 -0
  78. package/cli/selftune/localdb/materialize.ts +428 -0
  79. package/cli/selftune/localdb/queries.ts +376 -0
  80. package/cli/selftune/localdb/schema.ts +204 -0
  81. package/cli/selftune/memory/writer.ts +447 -0
  82. package/cli/selftune/monitoring/watch.ts +90 -16
  83. package/cli/selftune/normalization.ts +682 -0
  84. package/cli/selftune/observability.ts +19 -44
  85. package/cli/selftune/orchestrate.ts +1073 -0
  86. package/cli/selftune/quickstart.ts +203 -0
  87. package/cli/selftune/repair/skill-usage.ts +576 -0
  88. package/cli/selftune/schedule.ts +561 -0
  89. package/cli/selftune/status.ts +59 -33
  90. package/cli/selftune/sync.ts +627 -0
  91. package/cli/selftune/types.ts +525 -5
  92. package/cli/selftune/utils/canonical-log.ts +45 -0
  93. package/cli/selftune/utils/frontmatter.ts +217 -0
  94. package/cli/selftune/utils/hooks.ts +41 -0
  95. package/cli/selftune/utils/html.ts +27 -0
  96. package/cli/selftune/utils/llm-call.ts +103 -19
  97. package/cli/selftune/utils/math.ts +10 -0
  98. package/cli/selftune/utils/query-filter.ts +139 -0
  99. package/cli/selftune/utils/skill-discovery.ts +340 -0
  100. package/cli/selftune/utils/skill-log.ts +68 -0
  101. package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
  102. package/cli/selftune/utils/transcript.ts +307 -26
  103. package/cli/selftune/utils/trigger-check.ts +89 -0
  104. package/cli/selftune/utils/tui.ts +156 -0
  105. package/cli/selftune/workflows/discover.ts +254 -0
  106. package/cli/selftune/workflows/skill-md-writer.ts +288 -0
  107. package/cli/selftune/workflows/workflows.ts +188 -0
  108. package/package.json +28 -11
  109. package/packages/telemetry-contract/README.md +11 -0
  110. package/packages/telemetry-contract/fixtures/golden.json +87 -0
  111. package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
  112. package/packages/telemetry-contract/index.ts +1 -0
  113. package/packages/telemetry-contract/package.json +19 -0
  114. package/packages/telemetry-contract/src/index.ts +2 -0
  115. package/packages/telemetry-contract/src/types.ts +163 -0
  116. package/packages/telemetry-contract/src/validators.ts +109 -0
  117. package/skill/SKILL.md +180 -33
  118. package/skill/Workflows/AutoActivation.md +145 -0
  119. package/skill/Workflows/Badge.md +124 -0
  120. package/skill/Workflows/Baseline.md +144 -0
  121. package/skill/Workflows/Composability.md +107 -0
  122. package/skill/Workflows/Contribute.md +94 -0
  123. package/skill/Workflows/Cron.md +132 -0
  124. package/skill/Workflows/Dashboard.md +214 -0
  125. package/skill/Workflows/Doctor.md +63 -14
  126. package/skill/Workflows/Evals.md +110 -18
  127. package/skill/Workflows/EvolutionMemory.md +154 -0
  128. package/skill/Workflows/Evolve.md +181 -21
  129. package/skill/Workflows/EvolveBody.md +159 -0
  130. package/skill/Workflows/Grade.md +36 -31
  131. package/skill/Workflows/ImportSkillsBench.md +117 -0
  132. package/skill/Workflows/Ingest.md +142 -21
  133. package/skill/Workflows/Initialize.md +91 -23
  134. package/skill/Workflows/Orchestrate.md +139 -0
  135. package/skill/Workflows/Replay.md +91 -0
  136. package/skill/Workflows/Rollback.md +23 -4
  137. package/skill/Workflows/Schedule.md +61 -0
  138. package/skill/Workflows/Sync.md +88 -0
  139. package/skill/Workflows/UnitTest.md +150 -0
  140. package/skill/Workflows/Watch.md +33 -1
  141. package/skill/Workflows/Workflows.md +129 -0
  142. package/skill/assets/activation-rules-default.json +26 -0
  143. package/skill/assets/multi-skill-settings.json +63 -0
  144. package/skill/assets/single-skill-settings.json +57 -0
  145. package/skill/references/invocation-taxonomy.md +2 -2
  146. package/skill/references/logs.md +164 -2
  147. package/skill/references/setup-patterns.md +65 -0
  148. package/skill/references/version-history.md +40 -0
  149. package/skill/settings_snippet.json +23 -0
  150. package/templates/activation-rules-default.json +27 -0
  151. package/templates/multi-skill-settings.json +64 -0
  152. package/templates/single-skill-settings.json +58 -0
  153. package/dashboard/index.html +0 -1119
@@ -7,11 +7,88 @@
7
7
  * Appends one record per session to ~/.claude/session_telemetry_log.jsonl.
8
8
  */
9
9
 
10
- import { TELEMETRY_LOG } from "../constants.js";
11
- import type { SessionTelemetryRecord, StopPayload } from "../types.js";
12
- import { appendJsonl } from "../utils/jsonl.js";
10
+ import { closeSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { CANONICAL_LOG, ORCHESTRATE_LOCK, SIGNAL_LOG, TELEMETRY_LOG } from "../constants.js";
12
+ import {
13
+ appendCanonicalRecords,
14
+ buildCanonicalExecutionFact,
15
+ buildCanonicalSession,
16
+ type CanonicalBaseInput,
17
+ getLatestPromptIdentity,
18
+ } from "../normalization.js";
19
+ import type { ImprovementSignalRecord, SessionTelemetryRecord, StopPayload } from "../types.js";
20
+ import { appendJsonl, readJsonl } from "../utils/jsonl.js";
13
21
  import { parseTranscript } from "../utils/transcript.js";
14
22
 
23
+ const LOCK_STALE_MS = 30 * 60 * 1000;
24
+
25
+ /**
26
+ * Check for pending improvement signals and spawn a focused orchestrate run
27
+ * in the background if warranted. Fire-and-forget — the hook exits immediately.
28
+ *
29
+ * Returns true if a process was spawned, false otherwise.
30
+ */
31
+ export function maybeSpawnReactiveOrchestrate(
32
+ signalLogPath: string = SIGNAL_LOG,
33
+ lockPath: string = ORCHESTRATE_LOCK,
34
+ ): boolean {
35
+ try {
36
+ // Read pending signals
37
+ const signals = readJsonl<ImprovementSignalRecord>(signalLogPath);
38
+ const pending = signals.filter((s) => !s.consumed);
39
+ if (pending.length === 0) return false;
40
+
41
+ // Atomically claim the lock — openSync with "wx" fails if file exists
42
+ let fd: number;
43
+ try {
44
+ fd = openSync(lockPath, "wx");
45
+ writeFileSync(fd, JSON.stringify({ timestamp: new Date().toISOString(), pid: process.pid }));
46
+ closeSync(fd);
47
+ } catch (lockErr: unknown) {
48
+ // Lock exists — check if stale
49
+ if ((lockErr as NodeJS.ErrnoException).code === "EEXIST") {
50
+ try {
51
+ const lockContent = readFileSync(lockPath, "utf8");
52
+ const lock = JSON.parse(lockContent);
53
+ const lockAge = Date.now() - new Date(lock.timestamp).getTime();
54
+ if (lockAge < LOCK_STALE_MS) return false; // Active lock, skip
55
+ // Stale lock — override
56
+ writeFileSync(
57
+ lockPath,
58
+ JSON.stringify({ timestamp: new Date().toISOString(), pid: process.pid }),
59
+ );
60
+ } catch {
61
+ return false; // Can't read lock, skip
62
+ }
63
+ } else {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Spawn orchestrate in background (fire-and-forget)
69
+ try {
70
+ const proc = Bun.spawn(["selftune", "orchestrate", "--max-skills", "2"], {
71
+ stdout: "ignore",
72
+ stderr: "ignore",
73
+ stdin: "ignore",
74
+ });
75
+ proc.unref();
76
+ } catch {
77
+ // Spawn failed — release our lock
78
+ try {
79
+ unlinkSync(lockPath);
80
+ } catch {
81
+ /* ignore */
82
+ }
83
+ return false;
84
+ }
85
+
86
+ return true;
87
+ } catch {
88
+ return false; // Silent — hooks must never block Claude
89
+ }
90
+ }
91
+
15
92
  /**
16
93
  * Core processing logic, exported for testability.
17
94
  * Returns the record that was appended.
@@ -19,6 +96,8 @@ import { parseTranscript } from "../utils/transcript.js";
19
96
  export function processSessionStop(
20
97
  payload: StopPayload,
21
98
  logPath: string = TELEMETRY_LOG,
99
+ canonicalLogPath: string = CANONICAL_LOG,
100
+ promptStatePath?: string,
22
101
  ): SessionTelemetryRecord {
23
102
  const sessionId = typeof payload.session_id === "string" ? payload.session_id : "unknown";
24
103
  const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
@@ -36,6 +115,47 @@ export function processSessionStop(
36
115
  };
37
116
 
38
117
  appendJsonl(logPath, record);
118
+
119
+ // Emit canonical session + execution fact records (additive)
120
+ const baseInput: CanonicalBaseInput = {
121
+ platform: "claude_code",
122
+ capture_mode: "hook",
123
+ source_session_kind: "interactive",
124
+ session_id: sessionId,
125
+ raw_source_ref: {
126
+ path: transcriptPath || undefined,
127
+ event_type: "Stop",
128
+ },
129
+ };
130
+ const latestPrompt = getLatestPromptIdentity(sessionId, promptStatePath, canonicalLogPath);
131
+
132
+ const canonicalSession = buildCanonicalSession({
133
+ ...baseInput,
134
+ workspace_path: cwd || undefined,
135
+ });
136
+
137
+ const canonicalFact = buildCanonicalExecutionFact({
138
+ ...baseInput,
139
+ occurred_at: record.timestamp,
140
+ prompt_id: latestPrompt.last_actionable_prompt_id ?? latestPrompt.last_prompt_id,
141
+ tool_calls_json: metrics.tool_calls,
142
+ total_tool_calls: metrics.total_tool_calls,
143
+ bash_commands_redacted: metrics.bash_commands,
144
+ assistant_turns: metrics.assistant_turns,
145
+ errors_encountered: metrics.errors_encountered,
146
+ input_tokens: metrics.input_tokens,
147
+ output_tokens: metrics.output_tokens,
148
+ duration_ms: metrics.duration_ms,
149
+ });
150
+ appendCanonicalRecords([canonicalSession, canonicalFact], canonicalLogPath);
151
+
152
+ // Reactive: spawn focused orchestrate if pending improvement signals exist
153
+ try {
154
+ maybeSpawnReactiveOrchestrate();
155
+ } catch {
156
+ // silent — hooks must never block
157
+ }
158
+
39
159
  return record;
40
160
  }
41
161
 
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Claude Code PreToolUse hook: skill-change-guard.ts
4
+ *
5
+ * Fires before Write/Edit tool calls. If the target is a SKILL.md file,
6
+ * outputs a suggestion to run `selftune watch --skill <name>` to monitor
7
+ * the impact of the change.
8
+ *
9
+ * This is advisory only — exit code is always 0, never blocking.
10
+ * Uses session state to avoid repeating suggestions for the same skill.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { basename, dirname } from "node:path";
15
+ import { SESSION_STATE_DIR } from "../constants.js";
16
+ import type { PreToolUsePayload } from "../types.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Detection helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** Check if a tool call is a Write or Edit targeting a SKILL.md file. */
23
+ export function isSkillMdWrite(toolName: string, filePath: string): boolean {
24
+ if (toolName !== "Write" && toolName !== "Edit") return false;
25
+ return basename(filePath).toUpperCase() === "SKILL.MD";
26
+ }
27
+
28
+ /** Extract the skill folder name from a path ending in SKILL.md. */
29
+ export function extractSkillNameFromPath(filePath: string): string {
30
+ return basename(dirname(filePath)) || "unknown";
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Session state (minimal — just tracks which skills we've already warned about)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ interface GuardState {
38
+ session_id: string;
39
+ warned_skills: string[];
40
+ }
41
+
42
+ function loadGuardState(path: string, sessionId: string): GuardState {
43
+ if (!existsSync(path)) {
44
+ return { session_id: sessionId, warned_skills: [] };
45
+ }
46
+ try {
47
+ const data = JSON.parse(readFileSync(path, "utf-8")) as GuardState;
48
+ if (data.session_id === sessionId && Array.isArray(data.warned_skills)) {
49
+ return data;
50
+ }
51
+ } catch {
52
+ // corrupt — start fresh
53
+ }
54
+ return { session_id: sessionId, warned_skills: [] };
55
+ }
56
+
57
+ function saveGuardState(path: string, state: GuardState): void {
58
+ const dir = dirname(path);
59
+ if (!existsSync(dir)) {
60
+ mkdirSync(dir, { recursive: true });
61
+ }
62
+ writeFileSync(path, JSON.stringify(state, null, 2), "utf-8");
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Core processing logic
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Process a PreToolUse payload and return a suggestion string if the tool
71
+ * call is writing to a SKILL.md file that hasn't been warned about yet.
72
+ */
73
+ export function processPreToolUse(payload: PreToolUsePayload, statePath: string): string | null {
74
+ const filePath =
75
+ typeof payload.tool_input?.file_path === "string" ? payload.tool_input.file_path : "";
76
+
77
+ if (!isSkillMdWrite(payload.tool_name, filePath)) return null;
78
+
79
+ const skillName = extractSkillNameFromPath(filePath);
80
+ const sessionId = payload.session_id ?? "unknown";
81
+
82
+ // Check if we've already warned about this skill in this session
83
+ const state = loadGuardState(statePath, sessionId);
84
+ if (state.warned_skills.includes(skillName)) return null;
85
+
86
+ // Record that we warned about this skill
87
+ state.warned_skills.push(skillName);
88
+ saveGuardState(statePath, state);
89
+
90
+ return `Run \`selftune watch --skill ${skillName}\` to monitor the impact of this SKILL.md change.`;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // stdin main (only when executed directly, not when imported)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ if (import.meta.main) {
98
+ try {
99
+ const payload: PreToolUsePayload = JSON.parse(await Bun.stdin.text());
100
+ const sessionId = payload.session_id ?? "unknown";
101
+ const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
102
+ const statePath = `${SESSION_STATE_DIR}/guard-state-${safe}.json`;
103
+
104
+ const suggestion = processPreToolUse(payload, statePath);
105
+ if (suggestion) {
106
+ process.stderr.write(`[selftune] 💡 Suggestion: ${suggestion}\n`);
107
+ }
108
+ } catch {
109
+ // silent — hooks must never block Claude
110
+ }
111
+ process.exit(0);
112
+ }
@@ -10,10 +10,21 @@
10
10
  * `should_trigger: true` half of trigger evals.
11
11
  */
12
12
 
13
+ import { existsSync, readFileSync } from "node:fs";
13
14
  import { basename, dirname } from "node:path";
14
- import { SKILL_LOG } from "../constants.js";
15
+ import { CANONICAL_LOG, SKILL_LOG } from "../constants.js";
16
+ import {
17
+ appendCanonicalRecord,
18
+ buildCanonicalSkillInvocation,
19
+ type CanonicalBaseInput,
20
+ deriveInvocationMode,
21
+ derivePromptId,
22
+ deriveSkillInvocationId,
23
+ getLatestPromptIdentity,
24
+ } from "../normalization.js";
15
25
  import type { PostToolUsePayload, SkillUsageRecord } from "../types.js";
16
26
  import { appendJsonl } from "../utils/jsonl.js";
27
+ import { classifySkillPath } from "../utils/skill-discovery.js";
17
28
  import { getLastUserMessage } from "../utils/transcript.js";
18
29
 
19
30
  /**
@@ -25,13 +36,73 @@ export function extractSkillName(filePath: string): string | null {
25
36
  return basename(dirname(filePath)) || "unknown";
26
37
  }
27
38
 
39
+ /**
40
+ * Check whether the transcript contains a Skill tool invocation for the given
41
+ * skill name, indicating an actual skill use rather than casual browsing.
42
+ * Scans the transcript backwards for efficiency.
43
+ */
44
+ export function hasSkillToolInvocation(transcriptPath: string, skillName: string): boolean {
45
+ return countSkillToolInvocations(transcriptPath, skillName) > 0;
46
+ }
47
+
48
+ export function countSkillToolInvocations(transcriptPath: string, skillName: string): number {
49
+ if (!transcriptPath || !existsSync(transcriptPath)) return 0;
50
+
51
+ try {
52
+ const content = readFileSync(transcriptPath, "utf-8");
53
+ const lines = content.trim().split("\n");
54
+ let matches = 0;
55
+
56
+ for (let i = lines.length - 1; i >= 0; i--) {
57
+ let entry: Record<string, unknown>;
58
+ try {
59
+ entry = JSON.parse(lines[i]);
60
+ } catch {
61
+ continue;
62
+ }
63
+
64
+ const msg = (entry.message as Record<string, unknown>) ?? entry;
65
+ const role = (msg.role as string) ?? (entry.role as string) ?? "";
66
+ if (role !== "assistant") continue;
67
+
68
+ const entryContent = msg.content ?? entry.content ?? "";
69
+ if (!Array.isArray(entryContent)) continue;
70
+
71
+ for (const block of entryContent) {
72
+ if (typeof block !== "object" || block === null) continue;
73
+ const b = block as Record<string, unknown>;
74
+ if (b.type !== "tool_use") continue;
75
+
76
+ const toolName = (b.name as string) ?? "";
77
+ if (toolName === "Skill") {
78
+ const inp = (b.input as Record<string, unknown>) ?? {};
79
+ const skillArg = (inp.skill as string) ?? (inp.name as string) ?? "";
80
+ if (skillArg === skillName) matches += 1;
81
+ }
82
+ }
83
+ }
84
+
85
+ return matches;
86
+ } catch {
87
+ // silent — hooks must never block Claude
88
+ }
89
+
90
+ return 0;
91
+ }
92
+
28
93
  /**
29
94
  * Core processing logic, exported for testability.
30
95
  * Returns the record that was appended, or null if skipped.
96
+ *
97
+ * To reduce false triggers, checks whether the Read of SKILL.md was
98
+ * preceded by an actual Skill tool invocation in the same transcript.
99
+ * If not, the record is still logged but marked as triggered: false.
31
100
  */
32
101
  export function processToolUse(
33
102
  payload: PostToolUsePayload,
34
103
  logPath: string = SKILL_LOG,
104
+ canonicalLogPath: string = CANONICAL_LOG,
105
+ promptStatePath?: string,
35
106
  ): SkillUsageRecord | null {
36
107
  // Only care about Read tool
37
108
  if (payload.tool_name !== "Read") return null;
@@ -45,19 +116,64 @@ export function processToolUse(
45
116
  const transcriptPath = payload.transcript_path ?? "";
46
117
  const sessionId = payload.session_id ?? "unknown";
47
118
 
48
- const query = getLastUserMessage(transcriptPath) ?? "(query not found)";
119
+ const query = getLastUserMessage(transcriptPath);
120
+ if (!query) return null;
121
+
122
+ // Distinguish actual invocation from browsing by checking for a Skill tool call
123
+ const invocationCount = countSkillToolInvocations(transcriptPath, skillName);
124
+ const wasInvoked = invocationCount > 0;
125
+ const skillPathMetadata = classifySkillPath(filePath);
49
126
 
50
127
  const record: SkillUsageRecord = {
51
128
  timestamp: new Date().toISOString(),
52
129
  session_id: sessionId,
53
130
  skill_name: skillName,
54
131
  skill_path: filePath,
132
+ ...skillPathMetadata,
55
133
  query,
56
- triggered: true,
134
+ triggered: wasInvoked,
57
135
  source: "claude_code",
58
136
  };
59
137
 
60
138
  appendJsonl(logPath, record);
139
+
140
+ const baseInput: CanonicalBaseInput = {
141
+ platform: "claude_code",
142
+ capture_mode: "hook",
143
+ source_session_kind: "interactive",
144
+ session_id: sessionId,
145
+ raw_source_ref: {
146
+ path: transcriptPath || undefined,
147
+ event_type: "PostToolUse",
148
+ },
149
+ };
150
+ const latestPrompt = getLatestPromptIdentity(sessionId, promptStatePath, canonicalLogPath);
151
+ const promptId =
152
+ latestPrompt.last_actionable_prompt_id ??
153
+ latestPrompt.last_prompt_id ??
154
+ derivePromptId(sessionId, 0);
155
+ const { invocation_mode, confidence } = deriveInvocationMode({
156
+ has_skill_tool_call: wasInvoked,
157
+ has_skill_md_read: !wasInvoked,
158
+ });
159
+ const canonical = buildCanonicalSkillInvocation({
160
+ ...baseInput,
161
+ skill_invocation_id: deriveSkillInvocationId(
162
+ sessionId,
163
+ skillName,
164
+ Math.max(invocationCount - 1, 0),
165
+ ),
166
+ occurred_at: record.timestamp,
167
+ matched_prompt_id: promptId,
168
+ skill_name: skillName,
169
+ skill_path: filePath,
170
+ invocation_mode,
171
+ triggered: wasInvoked,
172
+ confidence,
173
+ tool_name: payload.tool_name,
174
+ });
175
+ appendCanonicalRecord(canonical, canonicalLogPath);
176
+
61
177
  return record;
62
178
  }
63
179