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.
- package/.claude/agents/diagnosis-analyst.md +156 -0
- package/.claude/agents/evolution-reviewer.md +180 -0
- package/.claude/agents/integration-guide.md +212 -0
- package/.claude/agents/pattern-analyst.md +160 -0
- package/CHANGELOG.md +46 -1
- package/README.md +105 -257
- package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
- package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
- package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
- package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
- package/apps/local-dashboard/dist/favicon.png +0 -0
- package/apps/local-dashboard/dist/index.html +17 -0
- package/apps/local-dashboard/dist/logo.png +0 -0
- package/apps/local-dashboard/dist/logo.svg +9 -0
- package/assets/BeforeAfter.gif +0 -0
- package/assets/FeedbackLoop.gif +0 -0
- package/assets/logo.svg +9 -0
- package/assets/skill-health-badge.svg +20 -0
- package/cli/selftune/activation-rules.ts +171 -0
- package/cli/selftune/badge/badge-data.ts +108 -0
- package/cli/selftune/badge/badge-svg.ts +212 -0
- package/cli/selftune/badge/badge.ts +99 -0
- package/cli/selftune/canonical-export.ts +183 -0
- package/cli/selftune/constants.ts +103 -1
- package/cli/selftune/contribute/bundle.ts +314 -0
- package/cli/selftune/contribute/contribute.ts +214 -0
- package/cli/selftune/contribute/sanitize.ts +162 -0
- package/cli/selftune/cron/setup.ts +266 -0
- package/cli/selftune/dashboard-contract.ts +202 -0
- package/cli/selftune/dashboard-server.ts +1049 -0
- package/cli/selftune/dashboard.ts +43 -156
- package/cli/selftune/eval/baseline.ts +248 -0
- package/cli/selftune/eval/composability-v2.ts +273 -0
- package/cli/selftune/eval/composability.ts +117 -0
- package/cli/selftune/eval/generate-unit-tests.ts +143 -0
- package/cli/selftune/eval/hooks-to-evals.ts +101 -16
- package/cli/selftune/eval/import-skillsbench.ts +221 -0
- package/cli/selftune/eval/synthetic-evals.ts +172 -0
- package/cli/selftune/eval/unit-test-cli.ts +152 -0
- package/cli/selftune/eval/unit-test.ts +196 -0
- package/cli/selftune/evolution/deploy-proposal.ts +142 -1
- package/cli/selftune/evolution/evidence.ts +26 -0
- package/cli/selftune/evolution/evolve-body.ts +586 -0
- package/cli/selftune/evolution/evolve.ts +825 -116
- package/cli/selftune/evolution/extract-patterns.ts +105 -16
- package/cli/selftune/evolution/pareto.ts +314 -0
- package/cli/selftune/evolution/propose-body.ts +171 -0
- package/cli/selftune/evolution/propose-description.ts +100 -2
- package/cli/selftune/evolution/propose-routing.ts +166 -0
- package/cli/selftune/evolution/refine-body.ts +141 -0
- package/cli/selftune/evolution/rollback.ts +21 -4
- package/cli/selftune/evolution/validate-body.ts +254 -0
- package/cli/selftune/evolution/validate-proposal.ts +257 -35
- package/cli/selftune/evolution/validate-routing.ts +177 -0
- package/cli/selftune/grading/auto-grade.ts +200 -0
- package/cli/selftune/grading/grade-session.ts +513 -42
- package/cli/selftune/grading/pre-gates.ts +104 -0
- package/cli/selftune/grading/results.ts +42 -0
- package/cli/selftune/hooks/auto-activate.ts +185 -0
- package/cli/selftune/hooks/evolution-guard.ts +165 -0
- package/cli/selftune/hooks/prompt-log.ts +172 -2
- package/cli/selftune/hooks/session-stop.ts +123 -3
- package/cli/selftune/hooks/skill-change-guard.ts +112 -0
- package/cli/selftune/hooks/skill-eval.ts +119 -3
- package/cli/selftune/index.ts +415 -48
- package/cli/selftune/ingestors/claude-replay.ts +377 -0
- package/cli/selftune/ingestors/codex-rollout.ts +345 -46
- package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
- package/cli/selftune/ingestors/openclaw-ingest.ts +573 -0
- package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
- package/cli/selftune/init.ts +376 -16
- package/cli/selftune/last.ts +14 -5
- package/cli/selftune/localdb/db.ts +63 -0
- package/cli/selftune/localdb/materialize.ts +428 -0
- package/cli/selftune/localdb/queries.ts +376 -0
- package/cli/selftune/localdb/schema.ts +204 -0
- package/cli/selftune/memory/writer.ts +447 -0
- package/cli/selftune/monitoring/watch.ts +90 -16
- package/cli/selftune/normalization.ts +682 -0
- package/cli/selftune/observability.ts +19 -44
- package/cli/selftune/orchestrate.ts +1073 -0
- package/cli/selftune/quickstart.ts +203 -0
- package/cli/selftune/repair/skill-usage.ts +576 -0
- package/cli/selftune/schedule.ts +561 -0
- package/cli/selftune/status.ts +59 -33
- package/cli/selftune/sync.ts +627 -0
- package/cli/selftune/types.ts +525 -5
- package/cli/selftune/utils/canonical-log.ts +45 -0
- package/cli/selftune/utils/frontmatter.ts +217 -0
- package/cli/selftune/utils/hooks.ts +41 -0
- package/cli/selftune/utils/html.ts +27 -0
- package/cli/selftune/utils/llm-call.ts +103 -19
- package/cli/selftune/utils/math.ts +10 -0
- package/cli/selftune/utils/query-filter.ts +139 -0
- package/cli/selftune/utils/skill-discovery.ts +340 -0
- package/cli/selftune/utils/skill-log.ts +68 -0
- package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
- package/cli/selftune/utils/transcript.ts +307 -26
- package/cli/selftune/utils/trigger-check.ts +89 -0
- package/cli/selftune/utils/tui.ts +156 -0
- package/cli/selftune/workflows/discover.ts +254 -0
- package/cli/selftune/workflows/skill-md-writer.ts +288 -0
- package/cli/selftune/workflows/workflows.ts +188 -0
- package/package.json +28 -11
- package/packages/telemetry-contract/README.md +11 -0
- package/packages/telemetry-contract/fixtures/golden.json +87 -0
- package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
- package/packages/telemetry-contract/index.ts +1 -0
- package/packages/telemetry-contract/package.json +19 -0
- package/packages/telemetry-contract/src/index.ts +2 -0
- package/packages/telemetry-contract/src/types.ts +163 -0
- package/packages/telemetry-contract/src/validators.ts +109 -0
- package/skill/SKILL.md +180 -33
- package/skill/Workflows/AutoActivation.md +145 -0
- package/skill/Workflows/Badge.md +124 -0
- package/skill/Workflows/Baseline.md +144 -0
- package/skill/Workflows/Composability.md +107 -0
- package/skill/Workflows/Contribute.md +94 -0
- package/skill/Workflows/Cron.md +132 -0
- package/skill/Workflows/Dashboard.md +214 -0
- package/skill/Workflows/Doctor.md +63 -14
- package/skill/Workflows/Evals.md +110 -18
- package/skill/Workflows/EvolutionMemory.md +154 -0
- package/skill/Workflows/Evolve.md +181 -21
- package/skill/Workflows/EvolveBody.md +159 -0
- package/skill/Workflows/Grade.md +36 -31
- package/skill/Workflows/ImportSkillsBench.md +117 -0
- package/skill/Workflows/Ingest.md +142 -21
- package/skill/Workflows/Initialize.md +91 -23
- package/skill/Workflows/Orchestrate.md +139 -0
- package/skill/Workflows/Replay.md +91 -0
- package/skill/Workflows/Rollback.md +23 -4
- package/skill/Workflows/Schedule.md +61 -0
- package/skill/Workflows/Sync.md +88 -0
- package/skill/Workflows/UnitTest.md +150 -0
- package/skill/Workflows/Watch.md +33 -1
- package/skill/Workflows/Workflows.md +129 -0
- package/skill/assets/activation-rules-default.json +26 -0
- package/skill/assets/multi-skill-settings.json +63 -0
- package/skill/assets/single-skill-settings.json +57 -0
- package/skill/references/invocation-taxonomy.md +2 -2
- package/skill/references/logs.md +164 -2
- package/skill/references/setup-patterns.md +65 -0
- package/skill/references/version-history.md +40 -0
- package/skill/settings_snippet.json +23 -0
- package/templates/activation-rules-default.json +27 -0
- package/templates/multi-skill-settings.json +64 -0
- package/templates/single-skill-settings.json +58 -0
- 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 {
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
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)
|
|
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:
|
|
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
|
|