selftune 0.2.21 → 0.2.22

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/README.md CHANGED
@@ -126,6 +126,9 @@ Your agent runs these — you just say what you want ("improve my skills", "show
126
126
  | | `selftune eval composability --skill <name>` | Detect conflicts between co-occurring skills |
127
127
  | | `selftune eval family-overlap --prefix sc-` | Detect sibling overlap and suggest when a skill family should be consolidated |
128
128
  | | `selftune eval import` | Import external eval corpus from [SkillsBench](https://github.com/benchflow-ai/skillsbench) |
129
+ | **hooks** | `selftune codex install` | Install selftune hooks into Codex (`--dry-run`, `--uninstall`) |
130
+ | | `selftune opencode install` | Install selftune hooks into OpenCode |
131
+ | | `selftune cline install` | Install selftune hooks into Cline |
129
132
  | **auto** | `selftune cron setup` | Install OS-level scheduling (cron/launchd/systemd) |
130
133
  | | `selftune watch --skill <name>` | Monitor after deploy. Auto-rollback on regression. |
131
134
  | **other** | `selftune workflows` | Discover and manage multi-skill workflows |
@@ -165,13 +168,15 @@ selftune is complementary to these tools, not competitive. They trace what happe
165
168
 
166
169
  ## Platforms
167
170
 
168
- **Claude Code** (fully supported) Hooks install automatically. `selftune ingest claude` backfills existing transcripts. This is the primary supported platform.
171
+ | Platform | Support | Real-time Hooks | Eval/Optimizer Agents | Batch Ingest | Config Location |
172
+ | --- | --- | --- | --- | --- | --- |
173
+ | **Claude Code** | Full | Automatic via `selftune init` | `claude --agent` (native) | `selftune ingest claude` | `~/.claude/settings.json` |
174
+ | **Codex** | Experimental | `selftune codex install` | `codex exec` (inlined) | `selftune ingest codex` | `~/.codex/hooks.json` |
175
+ | **OpenCode** | Experimental | `selftune opencode install` | `opencode run --agent` (native) | `selftune ingest opencode` | `./opencode.json` or `~/.config/opencode/opencode.json` |
176
+ | **Cline** | Experimental | `selftune cline install` | — | — | `~/Documents/Cline/Hooks/` |
177
+ | **OpenClaw** | Experimental | — | — | `selftune ingest openclaw` | — |
169
178
 
170
- **Codex** (experimental) `selftune ingest wrap-codex -- <args>` or `selftune ingest codex`. Adapter exists but is not actively tested. Skill attribution is conservative: selftune only records explicit Codex skill evidence, not incidental assistant/meta mentions.
171
-
172
- **OpenCode** (experimental) — `selftune ingest opencode`. Adapter exists but is not actively tested.
173
-
174
- **OpenClaw** (experimental) — `selftune ingest openclaw` + `selftune cron setup` for autonomous evolution. Adapter exists but is not actively tested.
179
+ OpenCode and Codex now support eval/optimizer agent workflows (evolution-reviewer, diagnosis-analyst, pattern-analyst, integration-guide). OpenCode agents are registered in the config during `selftune opencode install`; Codex inlines agent instructions into the prompt since it lacks a native `--agent` flag. OpenCode lacks a prompt-submission hook event, so prompt logging and auto-activate are unavailable. Cline only exposes PostToolUse and task lifecycle events, limiting coverage to commit tracking and session telemetry. All platforms write to the same shared log schema.
175
180
 
176
181
  Requires [Bun](https://bun.sh) or Node.js 18+. No extra API keys.
177
182
 
@@ -181,6 +186,6 @@ Requires [Bun](https://bun.sh) or Node.js 18+. No extra API keys.
181
186
 
182
187
  [Architecture](ARCHITECTURE.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md) · [Integration Guide](docs/integration-guide.md) · [Sponsor](https://github.com/sponsors/WellDunDun)
183
188
 
184
- MIT licensed. Free forever. Primary support for Claude Code; experimental adapters for Codex, OpenCode, and OpenClaw.
189
+ MIT licensed. Free forever. Hooks for Claude Code, Codex, OpenCode, and Cline; batch ingest for OpenClaw.
185
190
 
186
191
  </div>
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Cline hook adapter for selftune.
4
+ *
5
+ * Translates Cline hook events (PostToolUse, TaskComplete, TaskCancel)
6
+ * into selftune hook calls for commit tracking and session telemetry.
7
+ *
8
+ * Protocol: reads JSON from stdin, routes to the appropriate handler,
9
+ * and writes `{"cancel": false}` to stdout.
10
+ *
11
+ * Fail-open: never crashes, never blocks Cline. All errors are silent.
12
+ *
13
+ * Usage: echo '$HOOK_PAYLOAD' | selftune cline hook
14
+ */
15
+
16
+ import type { StopPayload } from "../../types.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Cline hook input shape
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface ClineHookInput {
23
+ hookName: string;
24
+ taskId: string;
25
+ workspaceRoots?: string[];
26
+ postToolUse?: {
27
+ toolName: string;
28
+ parameters: Record<string, unknown>;
29
+ result?: string;
30
+ success?: boolean;
31
+ };
32
+ taskComplete?: {
33
+ taskMetadata: { taskId: string; ulid: string };
34
+ };
35
+ taskCancel?: {
36
+ taskMetadata: { taskId: string; ulid: string };
37
+ };
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Helpers
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function outputResponse(): void {
45
+ process.stdout.write(JSON.stringify({ cancel: false }));
46
+ }
47
+
48
+ async function readStdin(): Promise<{ full: string }> {
49
+ const raw = await Bun.stdin.text();
50
+ return { full: raw };
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // PostToolUse handler — commit tracking (inline, fast path)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ async function handlePostToolUse(input: ClineHookInput): Promise<void> {
58
+ const { postToolUse, taskId } = input;
59
+ if (!postToolUse) return;
60
+
61
+ const { toolName, parameters, result } = postToolUse;
62
+
63
+ // Only care about execute_command that might be git commits
64
+ if (toolName !== "execute_command") return;
65
+
66
+ const command = typeof parameters.command === "string" ? parameters.command : "";
67
+ if (!command) return;
68
+
69
+ // Use selftune's commit-track logic
70
+ const { containsGitCommitCommand, parseCommitSha, parseCommitTitle, parseBranchFromOutput } =
71
+ await import("../../hooks/commit-track.js");
72
+
73
+ if (!containsGitCommitCommand(command)) return;
74
+ if (!result) return;
75
+
76
+ const commitSha = parseCommitSha(result);
77
+ if (!commitSha) return;
78
+
79
+ const commitTitle = parseCommitTitle(result);
80
+ const branch = parseBranchFromOutput(result);
81
+
82
+ // Write to SQLite
83
+ try {
84
+ const { writeCommitTracking } = await import("../../localdb/direct-write.js");
85
+ writeCommitTracking({
86
+ session_id: taskId,
87
+ commit_sha: commitSha,
88
+ commit_title: commitTitle,
89
+ branch,
90
+ timestamp: new Date().toISOString(),
91
+ });
92
+ } catch {
93
+ /* fail-open */
94
+ }
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // TaskComplete / TaskCancel handler — session telemetry (background)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ async function handleTaskEnd(input: ClineHookInput): Promise<void> {
102
+ const { taskId, workspaceRoots } = input;
103
+ const cwd = workspaceRoots?.[0] ?? process.cwd();
104
+
105
+ // Build a StopPayload compatible with selftune's session-stop processor
106
+ const payload: StopPayload = {
107
+ session_id: taskId,
108
+ cwd,
109
+ // Cline doesn't provide a transcript path in the same way Claude Code does.
110
+ // session-stop will still record session-level telemetry from what's available.
111
+ transcript_path: "",
112
+ };
113
+
114
+ try {
115
+ const { processSessionStop } = await import("../../hooks/session-stop.js");
116
+ await processSessionStop(payload);
117
+ } catch {
118
+ /* fail-open */
119
+ }
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Main entry point
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export async function cliMain(): Promise<void> {
127
+ try {
128
+ const { full } = await readStdin();
129
+
130
+ if (!full.trim()) {
131
+ outputResponse();
132
+ return;
133
+ }
134
+
135
+ let input: ClineHookInput;
136
+ try {
137
+ input = JSON.parse(full) as ClineHookInput;
138
+ } catch {
139
+ outputResponse();
140
+ return;
141
+ }
142
+
143
+ const { hookName } = input;
144
+ if (!hookName) {
145
+ outputResponse();
146
+ return;
147
+ }
148
+
149
+ if (hookName === "PostToolUse") {
150
+ await handlePostToolUse(input);
151
+ } else if (hookName === "TaskComplete" || hookName === "TaskCancel") {
152
+ await handleTaskEnd(input);
153
+ }
154
+ // Unknown events are silently ignored (fail-open)
155
+
156
+ outputResponse();
157
+ } catch {
158
+ // Fail-open: always output a valid response
159
+ outputResponse();
160
+ }
161
+ }
162
+
163
+ // --- stdin main (only when executed directly, not when imported) ---
164
+ if (import.meta.main) {
165
+ await cliMain();
166
+ process.exit(0);
167
+ }
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Install selftune hooks into Cline environment.
4
+ *
5
+ * Creates hook scripts in ~/Documents/Cline/Hooks/ for:
6
+ * - PostToolUse (inline — commit tracking, fast path)
7
+ * - TaskComplete (background — session telemetry)
8
+ * - TaskCancel (background — session cleanup)
9
+ *
10
+ * Each hook is a bash shim that pipes stdin to `npx selftune cline hook`.
11
+ *
12
+ * Usage: selftune cline install [--dry-run] [--uninstall]
13
+ */
14
+
15
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ const CLINE_HOOKS_DIR = join(homedir(), "Documents", "Cline", "Hooks");
20
+ const MARKER = "# selftune-managed";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Hook script generators
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Build a hook command that prefers SELFTUNE_CLI_PATH, then npx. */
27
+ const HOOK_CMD =
28
+ 'if [ -n "$SELFTUNE_CLI_PATH" ]; then "$SELFTUNE_CLI_PATH" cline hook; else npx selftune cline hook; fi';
29
+
30
+ function hookScript(hookName: string): string {
31
+ if (hookName === "PostToolUse") {
32
+ // Inline — commit tracking is fast; finish before Cline moves on.
33
+ // hook.ts writes {"cancel": false} to stdout, so we suppress only stderr.
34
+ return `#!/usr/bin/env bash
35
+ ${MARKER}
36
+ input=$(cat)
37
+ echo "$input" | (${HOOK_CMD}) 2>/dev/null || echo '{"cancel": false}'
38
+ `;
39
+ }
40
+
41
+ // Background — session telemetry upload can be slow; don't block Cline
42
+ return `#!/usr/bin/env bash
43
+ ${MARKER}
44
+ input=$(cat)
45
+ echo "$input" | (${HOOK_CMD}) &>/dev/null &
46
+ echo '{"cancel": false}'
47
+ `;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Hook definitions
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const HOOKS: Array<{ name: string; description: string }> = [
55
+ { name: "PostToolUse", description: "Track git commits via selftune" },
56
+ { name: "TaskComplete", description: "Record session telemetry when a Cline task completes" },
57
+ { name: "TaskCancel", description: "Record session telemetry when a Cline task is cancelled" },
58
+ ];
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Install
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function installHooks(dryRun: boolean): void {
65
+ console.log("Setting up selftune hooks for Cline...");
66
+ console.log(`Hooks directory: ${CLINE_HOOKS_DIR}`);
67
+ console.log("");
68
+
69
+ if (!dryRun) {
70
+ mkdirSync(CLINE_HOOKS_DIR, { recursive: true });
71
+ }
72
+
73
+ let installed = 0;
74
+ let skipped = 0;
75
+
76
+ for (const hook of HOOKS) {
77
+ const hookPath = join(CLINE_HOOKS_DIR, hook.name);
78
+
79
+ if (existsSync(hookPath)) {
80
+ const existing = readFileSync(hookPath, "utf-8");
81
+ if (existing.includes(MARKER)) {
82
+ if (dryRun) {
83
+ console.log(` Would update: ${hook.name}`);
84
+ } else {
85
+ writeFileSync(hookPath, hookScript(hook.name), { mode: 0o755 });
86
+ chmodSync(hookPath, 0o755);
87
+ console.log(` Updated: ${hook.name}`);
88
+ }
89
+ installed++;
90
+ } else {
91
+ console.log(` Skipped: ${hook.name} (existing hook not managed by selftune)`);
92
+ skipped++;
93
+ }
94
+ } else {
95
+ if (dryRun) {
96
+ console.log(` Would create: ${hook.name}`);
97
+ } else {
98
+ writeFileSync(hookPath, hookScript(hook.name), { mode: 0o755 });
99
+ console.log(` Created: ${hook.name}`);
100
+ }
101
+ installed++;
102
+ }
103
+ }
104
+
105
+ console.log("");
106
+ if (dryRun) {
107
+ console.log(`Dry run: ${installed} hook(s) would be installed.`);
108
+ } else if (installed > 0) {
109
+ console.log(`Installed ${installed} hook(s).`);
110
+ }
111
+ if (skipped > 0) {
112
+ console.log(`Skipped ${skipped} hook(s) with existing non-selftune content.`);
113
+ }
114
+ if (!dryRun && installed > 0) {
115
+ console.log("");
116
+ if (skipped === 0) {
117
+ console.log("Cline will now track commits and record session telemetry.");
118
+ } else {
119
+ console.log("Partial install: some hooks were skipped. Telemetry may be incomplete.");
120
+ }
121
+ console.log("Run `selftune status` to verify setup.");
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Uninstall
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function uninstallHooks(dryRun: boolean): void {
130
+ console.log("Removing selftune hooks from Cline...");
131
+ console.log("");
132
+
133
+ let removed = 0;
134
+ let skipped = 0;
135
+
136
+ for (const hook of HOOKS) {
137
+ const hookPath = join(CLINE_HOOKS_DIR, hook.name);
138
+
139
+ if (!existsSync(hookPath)) {
140
+ console.log(` Not found: ${hook.name}`);
141
+ continue;
142
+ }
143
+
144
+ const existing = readFileSync(hookPath, "utf-8");
145
+ if (!existing.includes(MARKER)) {
146
+ console.log(` Skipped: ${hook.name} (not managed by selftune)`);
147
+ skipped++;
148
+ continue;
149
+ }
150
+
151
+ if (dryRun) {
152
+ console.log(` Would remove: ${hook.name}`);
153
+ } else {
154
+ rmSync(hookPath);
155
+ console.log(` Removed: ${hook.name}`);
156
+ }
157
+ removed++;
158
+ }
159
+
160
+ console.log("");
161
+ if (dryRun) {
162
+ console.log(`Dry run: ${removed} hook(s) would be removed.`);
163
+ } else if (removed > 0) {
164
+ console.log(`Removed ${removed} hook(s).`);
165
+ }
166
+ if (skipped > 0) {
167
+ console.log(`Skipped ${skipped} hook(s) not managed by selftune.`);
168
+ }
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Main entry point
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export async function cliMain(): Promise<void> {
176
+ const args = process.argv.slice(2);
177
+ const dryRun = args.includes("--dry-run");
178
+ const uninstall = args.includes("--uninstall");
179
+
180
+ if (uninstall) {
181
+ uninstallHooks(dryRun);
182
+ } else {
183
+ installHooks(dryRun);
184
+ }
185
+ }
186
+
187
+ // --- stdin main (only when executed directly, not when imported) ---
188
+ if (import.meta.main) {
189
+ try {
190
+ await cliMain();
191
+ } catch (err) {
192
+ console.error(
193
+ `[selftune] Cline install failed: ${err instanceof Error ? err.message : String(err)}`,
194
+ );
195
+ process.exit(1);
196
+ }
197
+ }
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Codex hook adapter for selftune.
4
+ *
5
+ * Reads Codex hook payloads from stdin and delegates to shared selftune hook logic.
6
+ * Codex uses the same hook protocol as Claude Code (JSON on stdin, JSON on stdout),
7
+ * so the payloads are structurally identical.
8
+ *
9
+ * Usage: echo '$HOOK_PAYLOAD' | selftune codex hook
10
+ *
11
+ * Event routing:
12
+ * SessionStart -> prompt-log (processPrompt) + auto-activate (processAutoActivate)
13
+ * PreToolUse -> skill-change-guard + evolution-guard
14
+ * PostToolUse -> skill-eval (processToolUse) + commit-track (processCommitTrack)
15
+ * Stop -> session-stop (processSessionStop)
16
+ *
17
+ * Exit codes:
18
+ * 0 = success / allow
19
+ * 2 = block (PreToolUse guard rejection, Claude Code convention)
20
+ *
21
+ * Fail-open: any unhandled error -> exit 0, never crash the host agent.
22
+ */
23
+
24
+ import type {
25
+ PostToolUsePayload,
26
+ PreToolUsePayload,
27
+ PromptSubmitPayload,
28
+ StopPayload,
29
+ } from "../../types.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** Codex hook payload — superset of all event fields. */
36
+ export interface CodexHookPayload {
37
+ hook_event_name?: string;
38
+ session_id?: string;
39
+ transcript_path?: string;
40
+ cwd?: string;
41
+ tool_name?: string;
42
+ tool_input?: Record<string, unknown>;
43
+ tool_use_id?: string;
44
+ tool_response?: Record<string, unknown>;
45
+ prompt?: string;
46
+ user_prompt?: string;
47
+ permission_mode?: string;
48
+ stop_hook_active?: boolean;
49
+ last_assistant_message?: string;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ /** Response written to stdout. Empty object = no-op. */
54
+ type HookResponse = Record<string, unknown>;
55
+
56
+ const EMPTY_RESPONSE: HookResponse = {};
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Event handlers (dynamic imports for fast startup)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ async function handleSessionStart(payload: CodexHookPayload): Promise<HookResponse> {
63
+ // 1. Prompt logging
64
+ try {
65
+ const { processPrompt } = await import("../../hooks/prompt-log.js");
66
+ const promptPayload: PromptSubmitPayload = {
67
+ session_id: payload.session_id,
68
+ transcript_path: payload.transcript_path,
69
+ cwd: payload.cwd,
70
+ prompt: payload.prompt,
71
+ user_prompt: payload.user_prompt,
72
+ hook_event_name: "UserPromptSubmit",
73
+ };
74
+ await processPrompt(promptPayload);
75
+ } catch {
76
+ // fail-open
77
+ }
78
+
79
+ // 2. Auto-activate suggestions
80
+ let response: HookResponse = EMPTY_RESPONSE;
81
+ try {
82
+ const { processAutoActivate } = await import("../../hooks/auto-activate.js");
83
+ const sessionId = payload.session_id ?? "unknown";
84
+ const suggestions = await processAutoActivate(sessionId);
85
+ if (suggestions.length > 0) {
86
+ const context = suggestions.map((s) => `[selftune] Suggestion: ${s}`).join("\n");
87
+ // Codex supports hookSpecificOutput.additionalContext like Claude Code
88
+ response = {
89
+ hookSpecificOutput: {
90
+ hookEventName: "SessionStart",
91
+ additionalContext: context,
92
+ },
93
+ };
94
+ }
95
+ } catch {
96
+ // fail-open
97
+ }
98
+
99
+ return response;
100
+ }
101
+
102
+ async function handlePreToolUse(
103
+ payload: CodexHookPayload,
104
+ ): Promise<{ response: HookResponse; exitCode: number }> {
105
+ const prePayload: PreToolUsePayload = {
106
+ tool_name: payload.tool_name ?? "",
107
+ tool_input: payload.tool_input ?? {},
108
+ tool_use_id: payload.tool_use_id,
109
+ session_id: payload.session_id,
110
+ transcript_path: payload.transcript_path,
111
+ cwd: payload.cwd,
112
+ permission_mode: payload.permission_mode,
113
+ hook_event_name: "PreToolUse",
114
+ };
115
+
116
+ // Import constants once for both guards
117
+ let constants:
118
+ | { EVOLUTION_AUDIT_LOG: string; SELFTUNE_CONFIG_DIR: string; SESSION_STATE_DIR: string }
119
+ | undefined;
120
+ try {
121
+ constants = await import("../../constants.js");
122
+ } catch {
123
+ // fail-open
124
+ }
125
+
126
+ // 1. Evolution guard (can block with exit 2)
127
+ try {
128
+ if (constants) {
129
+ const { processEvolutionGuard } = await import("../../hooks/evolution-guard.js");
130
+ const guardResult = await processEvolutionGuard(prePayload, {
131
+ auditLogPath: constants.EVOLUTION_AUDIT_LOG,
132
+ selftuneDir: constants.SELFTUNE_CONFIG_DIR,
133
+ });
134
+ if (guardResult) {
135
+ process.stderr.write(`${guardResult.message}\n`);
136
+ return { response: EMPTY_RESPONSE, exitCode: guardResult.exitCode };
137
+ }
138
+ }
139
+ } catch {
140
+ // fail-open
141
+ }
142
+
143
+ // 2. Skill change guard (advisory only, never blocks)
144
+ try {
145
+ if (constants) {
146
+ const { processPreToolUse } = await import("../../hooks/skill-change-guard.js");
147
+ const sessionId = payload.session_id ?? "unknown";
148
+ const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "_");
149
+ const statePath = `${constants.SESSION_STATE_DIR}/guard-state-${safe}.json`;
150
+ const suggestion = processPreToolUse(prePayload, statePath);
151
+ if (suggestion) {
152
+ process.stderr.write(`[selftune] Suggestion: ${suggestion}\n`);
153
+ }
154
+ }
155
+ } catch {
156
+ // fail-open
157
+ }
158
+
159
+ return { response: EMPTY_RESPONSE, exitCode: 0 };
160
+ }
161
+
162
+ async function handlePostToolUse(payload: CodexHookPayload): Promise<HookResponse> {
163
+ const postPayload: PostToolUsePayload = {
164
+ tool_name: payload.tool_name ?? "",
165
+ tool_input: payload.tool_input ?? {},
166
+ tool_use_id: payload.tool_use_id,
167
+ tool_response: payload.tool_response,
168
+ session_id: payload.session_id,
169
+ transcript_path: payload.transcript_path,
170
+ cwd: payload.cwd,
171
+ permission_mode: payload.permission_mode,
172
+ hook_event_name: "PostToolUse",
173
+ };
174
+
175
+ // 1. Skill eval (Read/Skill tool usage tracking)
176
+ try {
177
+ const { processToolUse } = await import("../../hooks/skill-eval.js");
178
+ await processToolUse(postPayload);
179
+ } catch {
180
+ // fail-open
181
+ }
182
+
183
+ // 2. Commit tracking (git commit detection in Bash output)
184
+ try {
185
+ const { processCommitTrack } = await import("../../hooks/commit-track.js");
186
+ await processCommitTrack(postPayload);
187
+ } catch {
188
+ // fail-open
189
+ }
190
+
191
+ return EMPTY_RESPONSE;
192
+ }
193
+
194
+ async function handleStop(payload: CodexHookPayload): Promise<HookResponse> {
195
+ try {
196
+ const { processSessionStop } = await import("../../hooks/session-stop.js");
197
+ const stopPayload: StopPayload = {
198
+ session_id: payload.session_id,
199
+ transcript_path: payload.transcript_path,
200
+ cwd: payload.cwd,
201
+ permission_mode: payload.permission_mode,
202
+ stop_hook_active: payload.stop_hook_active,
203
+ last_assistant_message:
204
+ typeof payload.last_assistant_message === "string"
205
+ ? payload.last_assistant_message
206
+ : undefined,
207
+ hook_event_name: "Stop",
208
+ };
209
+ await processSessionStop(stopPayload);
210
+ } catch {
211
+ // fail-open
212
+ }
213
+ return EMPTY_RESPONSE;
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Main entry point
218
+ // ---------------------------------------------------------------------------
219
+
220
+ function writeResponseAndExit(response: HookResponse, code: number): void {
221
+ const data = JSON.stringify(response);
222
+ process.stdout.write(data, () => {
223
+ process.exit(code);
224
+ });
225
+ }
226
+
227
+ /**
228
+ * CLI entry point. Reads stdin, routes to the correct handler, writes response.
229
+ */
230
+ export async function cliMain(): Promise<void> {
231
+ let exitCode = 0;
232
+
233
+ try {
234
+ const { readStdinWithPreview } = await import("../../hooks/stdin-preview.js");
235
+ const { full } = await readStdinWithPreview();
236
+
237
+ // Fast-path: empty stdin -> no-op
238
+ if (!full.trim()) {
239
+ writeResponseAndExit(EMPTY_RESPONSE, 0);
240
+ return;
241
+ }
242
+
243
+ let payload: CodexHookPayload;
244
+ try {
245
+ payload = JSON.parse(full) as CodexHookPayload;
246
+ } catch {
247
+ writeResponseAndExit(EMPTY_RESPONSE, 0);
248
+ return;
249
+ }
250
+
251
+ const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
252
+
253
+ // Fast-path: use preview to skip irrelevant events without full routing
254
+ if (!eventName) {
255
+ writeResponseAndExit(EMPTY_RESPONSE, 0);
256
+ return;
257
+ }
258
+
259
+ let response: HookResponse = EMPTY_RESPONSE;
260
+
261
+ switch (eventName) {
262
+ case "SessionStart": {
263
+ response = await handleSessionStart(payload);
264
+ break;
265
+ }
266
+ case "PreToolUse": {
267
+ const result = await handlePreToolUse(payload);
268
+ response = result.response;
269
+ exitCode = result.exitCode;
270
+ break;
271
+ }
272
+ case "PostToolUse": {
273
+ response = await handlePostToolUse(payload);
274
+ break;
275
+ }
276
+ case "Stop": {
277
+ response = await handleStop(payload);
278
+ break;
279
+ }
280
+ default: {
281
+ // Unknown event — no-op
282
+ break;
283
+ }
284
+ }
285
+
286
+ writeResponseAndExit(response, exitCode);
287
+ } catch {
288
+ // Fail-open: never crash
289
+ writeResponseAndExit(EMPTY_RESPONSE, 0);
290
+ }
291
+ }
292
+
293
+ // --- stdin main (only when executed directly, not when imported) ---
294
+ if (import.meta.main) {
295
+ await cliMain();
296
+ }