@evo-dev/evodev 0.0.1-alpha → 0.0.1-alpha.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 (38) hide show
  1. package/dist/.agents/skills/grilling/SKILL.md +10 -0
  2. package/dist/.claude-plugin/marketplace.json +2 -2
  3. package/dist/assets/agents/review/code-reviewer/examples.md +1 -1
  4. package/dist/assets/agents/review/code-reviewer/prompt.md +1 -1
  5. package/dist/assets/agents/review/code-reviewer/verification.md +1 -1
  6. package/dist/assets/skills/coding/knowledge-distillation/SKILL.md +249 -0
  7. package/dist/assets/skills/coding/knowledge-distillation/manifest.json +10 -0
  8. package/dist/assets/skills/coding/knowledge-distillation/references/knowledge-distillation-methods.md +126 -0
  9. package/dist/assets/workflows/rd-bug-fix/WORKFLOW.json +1 -1
  10. package/dist/assets/workflows/rd-code-review/WORKFLOW.json +1 -1
  11. package/dist/assets/workflows/rd-docs-update/WORKFLOW.json +1 -1
  12. package/dist/assets/workflows/rd-feature-implementation/WORKFLOW.json +1 -1
  13. package/dist/assets/workflows/rd-refactor/WORKFLOW.json +1 -1
  14. package/dist/assets/workflows/rd-release-readiness/WORKFLOW.json +1 -1
  15. package/dist/assets/workflows/rd-security-boundary-review/WORKFLOW.json +2 -2
  16. package/dist/assets/workflows/rd-test-generation/WORKFLOW.json +1 -1
  17. package/dist/index.js +18925 -6426
  18. package/dist/plugins/evodev/.claude-plugin/plugin.json +2 -2
  19. package/dist/plugins/evodev/.codex-plugin/plugin.json +8 -6
  20. package/dist/plugins/evodev/.mcp.json +6 -0
  21. package/dist/plugins/evodev/hooks/codex-hooks.json +10 -10
  22. package/dist/plugins/evodev/hooks/codex.ts +596 -34
  23. package/dist/plugins/evodev/hooks/hooks.json +18 -18
  24. package/dist/plugins/evodev/hooks/hooks.ts +417 -25
  25. package/dist/plugins/evodev/hooks/index.ts +15 -0
  26. package/dist/plugins/evodev/hooks/paths.ts +44 -1
  27. package/dist/plugins/evodev/hooks/plugin.ts +160 -9
  28. package/dist/plugins/evodev/hooks/runtime.ts +227 -43
  29. package/dist/plugins/evodev/hooks/transform-agent.ts +30 -0
  30. package/dist/plugins/evodev/hooks/workspace-core.ts +154 -0
  31. package/dist/plugins/evodev/package.json +3 -3
  32. package/dist/plugins/evodev/skills/engineering-discipline/SKILL.md +63 -0
  33. package/dist/plugins/evodev/skills/engineering-discipline/anti-patterns.md +21 -0
  34. package/dist/plugins/evodev/skills/engineering-discipline/examples.md +19 -0
  35. package/dist/plugins/evodev/skills/engineering-discipline/verification.md +11 -0
  36. package/dist/plugins/evodev/skills/knowledge-distillation/SKILL.md +249 -0
  37. package/dist/plugins/evodev/skills/knowledge-distillation/references/knowledge-distillation-methods.md +126 -0
  38. package/package.json +3 -6
@@ -1,12 +1,12 @@
1
1
  {
2
- "description": "EvoDev routes Claude Code lifecycle events through evodev hook runtime for Task Contract, workflow, policy, and evidence control.",
2
+ "description": "EvoDev observes Claude Code lifecycle events through evodev hook runtime for local trace and action-required advisories.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
9
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
10
10
  "timeout": 30
11
11
  }
12
12
  ]
@@ -17,7 +17,7 @@
17
17
  "hooks": [
18
18
  {
19
19
  "type": "command",
20
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
20
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
21
21
  "timeout": 30
22
22
  }
23
23
  ]
@@ -29,7 +29,7 @@
29
29
  "hooks": [
30
30
  {
31
31
  "type": "command",
32
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
32
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
33
33
  "timeout": 30
34
34
  }
35
35
  ]
@@ -41,7 +41,7 @@
41
41
  "hooks": [
42
42
  {
43
43
  "type": "command",
44
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
44
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
45
45
  "timeout": 30
46
46
  }
47
47
  ]
@@ -53,7 +53,7 @@
53
53
  "hooks": [
54
54
  {
55
55
  "type": "command",
56
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
56
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
57
57
  "timeout": 30
58
58
  }
59
59
  ]
@@ -65,7 +65,7 @@
65
65
  "hooks": [
66
66
  {
67
67
  "type": "command",
68
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
68
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
69
69
  "timeout": 30
70
70
  }
71
71
  ]
@@ -76,7 +76,7 @@
76
76
  "hooks": [
77
77
  {
78
78
  "type": "command",
79
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
79
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
80
80
  "timeout": 30
81
81
  }
82
82
  ]
@@ -88,7 +88,7 @@
88
88
  "hooks": [
89
89
  {
90
90
  "type": "command",
91
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
91
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
92
92
  "timeout": 30
93
93
  }
94
94
  ]
@@ -99,7 +99,7 @@
99
99
  "hooks": [
100
100
  {
101
101
  "type": "command",
102
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
102
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
103
103
  "timeout": 30
104
104
  }
105
105
  ]
@@ -110,7 +110,7 @@
110
110
  "hooks": [
111
111
  {
112
112
  "type": "command",
113
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
113
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
114
114
  "timeout": 30
115
115
  }
116
116
  ]
@@ -121,7 +121,7 @@
121
121
  "hooks": [
122
122
  {
123
123
  "type": "command",
124
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
124
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
125
125
  "timeout": 30
126
126
  }
127
127
  ]
@@ -132,7 +132,7 @@
132
132
  "hooks": [
133
133
  {
134
134
  "type": "command",
135
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
135
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
136
136
  "timeout": 30
137
137
  }
138
138
  ]
@@ -143,7 +143,7 @@
143
143
  "hooks": [
144
144
  {
145
145
  "type": "command",
146
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
146
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
147
147
  "timeout": 30
148
148
  }
149
149
  ]
@@ -154,7 +154,7 @@
154
154
  "hooks": [
155
155
  {
156
156
  "type": "command",
157
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
157
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
158
158
  "timeout": 30
159
159
  }
160
160
  ]
@@ -165,7 +165,7 @@
165
165
  "hooks": [
166
166
  {
167
167
  "type": "command",
168
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
168
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
169
169
  "timeout": 30
170
170
  }
171
171
  ]
@@ -176,7 +176,7 @@
176
176
  "hooks": [
177
177
  {
178
178
  "type": "command",
179
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
179
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
180
180
  "timeout": 30
181
181
  }
182
182
  ]
@@ -187,7 +187,7 @@
187
187
  "hooks": [
188
188
  {
189
189
  "type": "command",
190
- "command": "NODE_PATH=\"$(dirname \"$(bun pm bin -g)\")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}\" bun run \"${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts\" hook runtime --target claude",
190
+ "command": "cd \"${CLAUDE_PLUGIN_ROOT}\" && bun run hooks/runtime.ts hook runtime --target claude",
191
191
  "timeout": 5
192
192
  }
193
193
  ]
@@ -1,16 +1,31 @@
1
+ import type { Dirent } from "node:fs";
2
+ import { copyFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
1
5
  import {
6
+ CANONICAL_HOOK_EVENT_TYPES,
2
7
  type CanonicalHookEventType,
3
8
  type HookEventV1,
4
9
  type HookInputEventType,
5
10
  normalizeHookEvent,
6
11
  } from "@evo-dev/core";
7
- import { type ClaudePaths, resolveClaudePaths } from "./paths.ts";
12
+ import {
13
+ type ClaudePaths,
14
+ type CodexPaths,
15
+ resolveClaudePaths,
16
+ resolveCodexPaths,
17
+ } from "./paths.ts";
8
18
 
9
19
  export interface ClaudeHookInstallDryRunInput {
10
20
  homeDir: string;
11
21
  paths?: ClaudePaths;
12
22
  }
13
23
 
24
+ export interface CodexHookInstallDryRunInput {
25
+ homeDir: string;
26
+ paths?: CodexPaths;
27
+ }
28
+
14
29
  export interface ClaudeHookInstallDryRunPlan {
15
30
  target: "claude";
16
31
  settingsPath: string;
@@ -20,7 +35,27 @@ export interface ClaudeHookInstallDryRunPlan {
20
35
  reason: string;
21
36
  }>;
22
37
  warnings: string[];
23
- blockers: string[];
38
+ advisories: string[];
39
+ }
40
+
41
+ export interface CodexHookInstallDryRunPlan {
42
+ target: "codex";
43
+ configPath: string;
44
+ plannedWrites: Array<{
45
+ action: "merge-with-backup" | "create";
46
+ targetPath: string;
47
+ reason: string;
48
+ }>;
49
+ warnings: string[];
50
+ advisories: string[];
51
+ }
52
+
53
+ export interface HookInstallResult {
54
+ target: "claude" | "codex";
55
+ targetPath: string;
56
+ backupPath: string | null;
57
+ changed: boolean;
58
+ warnings: string[];
24
59
  }
25
60
 
26
61
  export interface ClaudeHookCommand {
@@ -44,9 +79,21 @@ export interface CodexHookConfig {
44
79
  }
45
80
 
46
81
  export const CLAUDE_HOOK_RUNTIME_COMMAND =
47
- 'NODE_PATH="$(dirname "$(bun pm bin -g)")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}" bun run "${CLAUDE_PLUGIN_ROOT}/hooks/runtime.ts" hook runtime --target claude';
48
- export const CODEX_HOOK_RUNTIME_COMMAND =
49
- 'NODE_PATH="$(dirname "$(bun pm bin -g)")/install/global/node_modules${NODE_PATH:+:$NODE_PATH}" bun run "${PLUGIN_ROOT}/hooks/runtime.ts" hook runtime --target codex';
82
+ 'cd "${CLAUDE_PLUGIN_ROOT}" && bun run hooks/runtime.ts hook runtime --target claude';
83
+ export const DEFAULT_CLAUDE_PLUGIN_SELECTOR = "evodev@evo-dev";
84
+ export const CODEX_HOOK_RUNTIME_COMMAND = [
85
+ "sh -lc '",
86
+ 'root="${PLUGIN_ROOT:-}"; ',
87
+ 'if [ -z "$root" ]; then ',
88
+ 'for candidate in "$HOME/.codex/plugins/cache/evodev/evodev"/* "$HOME/.codex/plugins/evodev"; do ',
89
+ 'if [ -f "$candidate/hooks/runtime.ts" ]; then root="$candidate"; break; fi; ',
90
+ "done; ",
91
+ "fi; ",
92
+ 'if [ -z "$root" ] || [ ! -f "$root/hooks/runtime.ts" ]; then exit 0; fi; ',
93
+ 'cd "$root" && bun run hooks/runtime.ts hook runtime --target codex || exit 0',
94
+ "'",
95
+ ].join("");
96
+ const CODEX_FEATURE_FLAGS = ["plugins", "plugin_hooks", "hooks"] as const;
50
97
  const CLAUDE_TOOL_EVENTS = new Set<CanonicalHookEventType>([
51
98
  "PreToolUse",
52
99
  "PermissionRequest",
@@ -94,6 +141,10 @@ const CODEX_EVENT_MATCHERS: Partial<Record<CanonicalHookEventType, string>> = {
94
141
  SubagentStop: "*",
95
142
  Stop: "*",
96
143
  };
144
+ const RUNTIME_HOOK_EVENT_TYPES = [...CANONICAL_HOOK_EVENT_TYPES, "AgentStop"] as const;
145
+ const RUNTIME_HOOK_EVENT_TYPE_BY_KEY = new Map<string, HookInputEventType>(
146
+ RUNTIME_HOOK_EVENT_TYPES.map((type) => [normalizeRuntimeHookEventName(type), type]),
147
+ );
97
148
 
98
149
  export function planClaudeHookInstallDryRun(
99
150
  input: ClaudeHookInstallDryRunInput,
@@ -107,17 +158,173 @@ export function planClaudeHookInstallDryRun(
107
158
  action: "merge-with-backup",
108
159
  targetPath: paths.settingsPath,
109
160
  reason:
110
- "dry-run only: would add disabled EvoDev user-level hooks after future confirmation; no file is written",
161
+ "would add or replace EvoDev-managed user-level hook entries without touching user-owned hooks",
111
162
  },
112
163
  ],
113
- warnings: [
114
- "Hooks remain disabled by default.",
115
- "Project .claude, .codex, CLAUDE.md, and AGENTS.md are never targeted by this dry-run.",
164
+ warnings: ["Project .claude, .codex, CLAUDE.md, and AGENTS.md are not targeted."],
165
+ advisories: [],
166
+ };
167
+ }
168
+
169
+ export function planCodexHookInstallDryRun(
170
+ input: CodexHookInstallDryRunInput,
171
+ ): CodexHookInstallDryRunPlan {
172
+ const paths = input.paths ?? resolveCodexPaths({ homeDir: input.homeDir });
173
+ return {
174
+ target: "codex",
175
+ configPath: paths.configPath,
176
+ plannedWrites: [
177
+ {
178
+ action: "merge-with-backup",
179
+ targetPath: paths.configPath,
180
+ reason:
181
+ "would enable missing Codex plugin hook feature flags while preserving explicit user-owned false values",
182
+ },
116
183
  ],
117
- blockers: [],
184
+ warnings: ["Codex hook definitions are supplied by the EvoDev Codex plugin manifest."],
185
+ advisories: [],
118
186
  };
119
187
  }
120
188
 
189
+ export async function installClaudeHooks(input: {
190
+ homeDir: string;
191
+ paths?: ClaudePaths;
192
+ now?: string;
193
+ pluginSelector?: string;
194
+ }): Promise<HookInstallResult> {
195
+ const paths = input.paths ?? resolveClaudePaths({ homeDir: input.homeDir });
196
+ const existing = await readTextIfExists(paths.settingsPath);
197
+ const runtimeCommand = await resolveClaudeUserHookRuntimeCommand({
198
+ paths,
199
+ pluginSelector: input.pluginSelector ?? DEFAULT_CLAUDE_PLUGIN_SELECTOR,
200
+ });
201
+ const next = mergeClaudeSettingsHooks(existing, createClaudeHookConfig(runtimeCommand).hooks);
202
+ return writeMergedConfig({
203
+ target: "claude",
204
+ path: paths.settingsPath,
205
+ existing,
206
+ next,
207
+ now: input.now,
208
+ warnings: [],
209
+ });
210
+ }
211
+
212
+ async function resolveClaudeUserHookRuntimeCommand(input: {
213
+ paths: ClaudePaths;
214
+ pluginSelector: string;
215
+ }): Promise<string> {
216
+ const runtimePath =
217
+ (await resolveInstalledClaudePluginRuntimePath(input)) ?? (await resolveLocalRuntimePath());
218
+ const pluginRoot = dirname(dirname(runtimePath));
219
+ return `cd ${shellQuote(pluginRoot)} && bun run hooks/runtime.ts hook runtime --target claude`;
220
+ }
221
+
222
+ async function resolveInstalledClaudePluginRuntimePath(input: {
223
+ paths: ClaudePaths;
224
+ pluginSelector: string;
225
+ }): Promise<string | null> {
226
+ const marketplaceName = extractMarketplaceNameFromSelector(input.pluginSelector);
227
+ const pluginName = extractPluginNameFromSelector(input.pluginSelector);
228
+ if (marketplaceName === null || pluginName === null) return null;
229
+ if (!isSafePluginCacheSegment(marketplaceName) || !isSafePluginCacheSegment(pluginName)) {
230
+ return null;
231
+ }
232
+
233
+ const cacheRoot = join(input.paths.claudeDir, "plugins", "cache", marketplaceName, pluginName);
234
+ let entries: Dirent[];
235
+ try {
236
+ entries = await readdir(cacheRoot, { withFileTypes: true });
237
+ } catch (error) {
238
+ if (isNotFoundError(error)) return null;
239
+ throw error;
240
+ }
241
+
242
+ const candidates = (
243
+ await Promise.all(
244
+ entries
245
+ .filter((entry) => entry.isDirectory())
246
+ .map(async (entry) => {
247
+ const runtimePath = join(cacheRoot, entry.name, "hooks", "runtime.ts");
248
+ try {
249
+ const runtimeStat = await stat(runtimePath);
250
+ return runtimeStat.isFile()
251
+ ? { path: runtimePath, mtimeMs: runtimeStat.mtimeMs }
252
+ : null;
253
+ } catch (error) {
254
+ if (isNotFoundError(error)) return null;
255
+ throw error;
256
+ }
257
+ }),
258
+ )
259
+ ).filter((candidate): candidate is { path: string; mtimeMs: number } => candidate !== null);
260
+
261
+ if (candidates.length === 0) return null;
262
+ candidates.sort(
263
+ (left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path),
264
+ );
265
+ return candidates[0].path;
266
+ }
267
+
268
+ function extractMarketplaceNameFromSelector(selector: string): string | null {
269
+ const separator = selector.lastIndexOf("@");
270
+ if (separator <= 0 || separator === selector.length - 1) return null;
271
+ const marketplaceName = selector.slice(separator + 1).trim();
272
+ return marketplaceName === "" ? null : marketplaceName;
273
+ }
274
+
275
+ function extractPluginNameFromSelector(selector: string): string | null {
276
+ const separator = selector.lastIndexOf("@");
277
+ const pluginName = (separator <= 0 ? selector : selector.slice(0, separator)).trim();
278
+ return pluginName === "" ? null : pluginName;
279
+ }
280
+
281
+ function isSafePluginCacheSegment(segment: string): boolean {
282
+ return /^[A-Za-z0-9._-]+$/.test(segment) && segment !== "." && segment !== "..";
283
+ }
284
+
285
+ async function resolveLocalRuntimePath(): Promise<string> {
286
+ const currentDir = dirname(fileURLToPath(import.meta.url));
287
+ const candidates = [
288
+ join(currentDir, "runtime.ts"),
289
+ join(currentDir, "plugins", "evodev", "hooks", "runtime.ts"),
290
+ ];
291
+
292
+ for (const candidate of candidates) {
293
+ try {
294
+ const candidateStat = await stat(candidate);
295
+ if (candidateStat.isFile()) return candidate;
296
+ } catch (error) {
297
+ if (isNotFoundError(error)) continue;
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ throw new Error(
303
+ `Cannot resolve Claude user-level hook runtime path. Checked: ${candidates.join(", ")}`,
304
+ );
305
+ }
306
+
307
+ export async function installCodexHooks(input: {
308
+ homeDir: string;
309
+ paths?: CodexPaths;
310
+ now?: string;
311
+ }): Promise<HookInstallResult> {
312
+ const paths = input.paths ?? resolveCodexPaths({ homeDir: input.homeDir });
313
+ const existing = await readTextIfExists(paths.configPath);
314
+ const merged = mergeCodexFeatureFlags(existing);
315
+ return writeMergedConfig({
316
+ target: "codex",
317
+ path: paths.configPath,
318
+ existing,
319
+ next: merged.content,
320
+ now: input.now,
321
+ warnings: [
322
+ "Codex hook definitions are loaded from the installed EvoDev Codex plugin.",
323
+ ...merged.warnings,
324
+ ],
325
+ });
326
+ }
327
+
121
328
  export function normalizeClaudeHookPayload(input: {
122
329
  type: HookInputEventType;
123
330
  payload: Record<string, unknown>;
@@ -136,12 +343,11 @@ export function normalizeClaudeRuntimeHookPayload(input: {
136
343
  payload: Record<string, unknown>;
137
344
  receivedAt?: string;
138
345
  }): HookEventV1 {
139
- const type = input.payload.hook_event_name ?? input.payload.hookEventName;
140
- if (typeof type !== "string" || type.trim() === "") {
141
- throw new Error("Claude hook payload is missing hook_event_name.");
142
- }
143
346
  return normalizeClaudeHookPayload({
144
- type: type as HookInputEventType,
347
+ type: readRuntimeHookEventType(
348
+ input.payload.hook_event_name ?? input.payload.hookEventName,
349
+ "Claude",
350
+ ),
145
351
  payload: input.payload,
146
352
  receivedAt: input.receivedAt,
147
353
  });
@@ -165,21 +371,34 @@ export function normalizeCodexRuntimeHookPayload(input: {
165
371
  payload: Record<string, unknown>;
166
372
  receivedAt?: string;
167
373
  }): HookEventV1 {
168
- const type =
169
- input.payload.hook_event_name ??
170
- input.payload.hookEventName ??
171
- input.payload.event ??
172
- input.payload.name;
173
- if (typeof type !== "string" || type.trim() === "") {
174
- throw new Error("Codex hook payload is missing hook_event_name.");
175
- }
176
374
  return normalizeCodexHookPayload({
177
- type: type as HookInputEventType,
375
+ type: readRuntimeHookEventType(
376
+ input.payload.hook_event_name ??
377
+ input.payload.hookEventName ??
378
+ input.payload.event ??
379
+ input.payload.name,
380
+ "Codex",
381
+ ),
178
382
  payload: input.payload,
179
383
  receivedAt: input.receivedAt,
180
384
  });
181
385
  }
182
386
 
387
+ function readRuntimeHookEventType(value: unknown, target: "Claude" | "Codex"): HookInputEventType {
388
+ if (typeof value !== "string" || value.trim() === "") {
389
+ throw new Error(`${target} hook payload is missing hook_event_name.`);
390
+ }
391
+ const normalized = RUNTIME_HOOK_EVENT_TYPE_BY_KEY.get(normalizeRuntimeHookEventName(value));
392
+ if (normalized === undefined) {
393
+ throw new Error(`Unsupported hook event type: ${value}`);
394
+ }
395
+ return normalized;
396
+ }
397
+
398
+ function normalizeRuntimeHookEventName(value: string): string {
399
+ return value.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
400
+ }
401
+
183
402
  export function createClaudeHookConfig(
184
403
  command: string = CLAUDE_HOOK_RUNTIME_COMMAND,
185
404
  ): ClaudeHookConfig {
@@ -194,7 +413,7 @@ export function createClaudeHookConfig(
194
413
  }
195
414
  return {
196
415
  description:
197
- "EvoDev routes Claude Code lifecycle events through evodev hook runtime for Task Contract, workflow, policy, and evidence control.",
416
+ "EvoDev observes Claude Code lifecycle events through evodev hook runtime for local trace and action-required advisories.",
198
417
  hooks,
199
418
  };
200
419
  }
@@ -215,3 +434,176 @@ export function createCodexHookConfig(
215
434
  }
216
435
  return { hooks };
217
436
  }
437
+
438
+ async function readTextIfExists(path: string): Promise<string | null> {
439
+ try {
440
+ return await readFile(path, "utf8");
441
+ } catch (error) {
442
+ if (isNotFoundError(error)) return null;
443
+ throw error;
444
+ }
445
+ }
446
+
447
+ async function writeMergedConfig(input: {
448
+ target: "claude" | "codex";
449
+ path: string;
450
+ existing: string | null;
451
+ next: string;
452
+ now?: string;
453
+ warnings: string[];
454
+ }): Promise<HookInstallResult> {
455
+ if (input.existing === input.next) {
456
+ return {
457
+ target: input.target,
458
+ targetPath: input.path,
459
+ backupPath: null,
460
+ changed: false,
461
+ warnings: input.warnings,
462
+ };
463
+ }
464
+
465
+ await mkdir(dirname(input.path), { recursive: true });
466
+ let backupPath: string | null = null;
467
+ if (input.existing !== null && (await fileExists(input.path))) {
468
+ backupPath = `${input.path}.evodev-${backupTimestamp(input.now)}.bak`;
469
+ await copyFile(input.path, backupPath);
470
+ }
471
+ await writeFile(input.path, input.next, "utf8");
472
+ return {
473
+ target: input.target,
474
+ targetPath: input.path,
475
+ backupPath,
476
+ changed: true,
477
+ warnings: input.warnings,
478
+ };
479
+ }
480
+
481
+ function mergeClaudeSettingsHooks(
482
+ existing: string | null,
483
+ evodevHooks: ClaudeHookConfig["hooks"],
484
+ ): string {
485
+ const settings = parseJsonConfig(existing, "Claude settings");
486
+ const currentHooks = isRecord(settings.hooks) ? settings.hooks : {};
487
+ const nextHooks: Record<string, unknown> = { ...currentHooks };
488
+
489
+ for (const [eventName, groups] of Object.entries(evodevHooks)) {
490
+ const existingGroups = Array.isArray(currentHooks[eventName]) ? currentHooks[eventName] : [];
491
+ nextHooks[eventName] = [
492
+ ...existingGroups.filter((group) => !isEvoDevHookGroup(group, "claude")),
493
+ ...(groups ?? []),
494
+ ];
495
+ }
496
+
497
+ return `${JSON.stringify({ ...settings, hooks: nextHooks }, null, 2)}\n`;
498
+ }
499
+
500
+ function parseJsonConfig(existing: string | null, label: string): Record<string, unknown> {
501
+ if (existing === null || existing.trim() === "") return {};
502
+ const parsed = JSON.parse(existing) as unknown;
503
+ if (!isRecord(parsed)) throw new Error(`${label} must be a JSON object.`);
504
+ return parsed;
505
+ }
506
+
507
+ function isEvoDevHookGroup(group: unknown, target: "claude" | "codex"): boolean {
508
+ if (!isRecord(group) || !Array.isArray(group.hooks)) return false;
509
+ return group.hooks.some((hook) => {
510
+ if (!isRecord(hook) || typeof hook.command !== "string") return false;
511
+ return hook.command.includes(`hook runtime --target ${target}`);
512
+ });
513
+ }
514
+
515
+ function mergeCodexFeatureFlags(existing: string | null): { content: string; warnings: string[] } {
516
+ const source = existing ?? "";
517
+ const lines = source === "" ? [] : source.replace(/\n$/, "").split("\n");
518
+ const section = findTomlSection(lines, "features");
519
+ if (section === null) {
520
+ const prefix = lines.length === 0 ? [] : [...lines, ""];
521
+ return {
522
+ content: [
523
+ ...prefix,
524
+ "[features]",
525
+ ...CODEX_FEATURE_FLAGS.map((flag) => `${flag} = true`),
526
+ "",
527
+ ].join("\n"),
528
+ warnings: [],
529
+ };
530
+ }
531
+
532
+ const nextLines = [...lines];
533
+ const seen = new Set<string>();
534
+ const preservedFalseFlags: string[] = [];
535
+ for (let index = section.start + 1; index < section.end; index += 1) {
536
+ const match = nextLines[index]?.match(/^(\s*)([A-Za-z0-9_-]+)(\s*=\s*)(.*)$/);
537
+ if (match === null) continue;
538
+ const key = match[2];
539
+ if ((CODEX_FEATURE_FLAGS as readonly string[]).includes(key)) {
540
+ seen.add(key);
541
+ if (isTomlBooleanFalse(match[4])) {
542
+ preservedFalseFlags.push(key);
543
+ } else {
544
+ nextLines[index] = `${match[1]}${key}${match[3]}true`;
545
+ }
546
+ }
547
+ }
548
+
549
+ const missing = CODEX_FEATURE_FLAGS.filter((flag) => !seen.has(flag));
550
+ nextLines.splice(section.end, 0, ...missing.map((flag) => `${flag} = true`));
551
+ return {
552
+ content: `${nextLines.join("\n")}\n`,
553
+ warnings:
554
+ preservedFalseFlags.length === 0
555
+ ? []
556
+ : [
557
+ `Explicitly disabled Codex feature flags were preserved: ${preservedFalseFlags.join(", ")}.`,
558
+ ],
559
+ };
560
+ }
561
+
562
+ function isTomlBooleanFalse(value: string): boolean {
563
+ return /^false(?:\s*(?:#.*)?)?$/i.test(value.trim());
564
+ }
565
+
566
+ function findTomlSection(lines: string[], name: string): { start: number; end: number } | null {
567
+ const header = `[${name}]`;
568
+ const start = lines.findIndex((line) => line.trim() === header);
569
+ if (start < 0) return null;
570
+ let end = lines.length;
571
+ for (let index = start + 1; index < lines.length; index += 1) {
572
+ if (/^\s*\[[^\]]+\]\s*$/.test(lines[index] ?? "")) {
573
+ end = index;
574
+ break;
575
+ }
576
+ }
577
+ return { start, end };
578
+ }
579
+
580
+ function backupTimestamp(value?: string): string {
581
+ const source = value ?? new Date().toISOString();
582
+ return source.replace(/[^0-9A-Za-z]/g, "").slice(0, 20) || "now";
583
+ }
584
+
585
+ function shellQuote(value: string): string {
586
+ return `'${value.replace(/'/g, "'\\''")}'`;
587
+ }
588
+
589
+ async function fileExists(path: string): Promise<boolean> {
590
+ try {
591
+ return (await stat(path)).isFile();
592
+ } catch (error) {
593
+ if (isNotFoundError(error)) return false;
594
+ throw error;
595
+ }
596
+ }
597
+
598
+ function isNotFoundError(error: unknown): boolean {
599
+ return (
600
+ typeof error === "object" &&
601
+ error !== null &&
602
+ "code" in error &&
603
+ (error as { code?: unknown }).code === "ENOENT"
604
+ );
605
+ }
606
+
607
+ function isRecord(value: unknown): value is Record<string, unknown> {
608
+ return typeof value === "object" && value !== null && !Array.isArray(value);
609
+ }