@moreih29/nexus-core 0.11.0 → 0.13.0

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 (210) hide show
  1. package/README.md +48 -63
  2. package/assets/agents/architect/body.ko.md +177 -0
  3. package/{agents → assets/agents}/architect/body.md +16 -0
  4. package/assets/agents/designer/body.ko.md +125 -0
  5. package/{agents → assets/agents}/designer/body.md +16 -0
  6. package/assets/agents/engineer/body.ko.md +106 -0
  7. package/{agents → assets/agents}/engineer/body.md +14 -0
  8. package/assets/agents/lead/body.ko.md +70 -0
  9. package/assets/agents/lead/body.md +70 -0
  10. package/assets/agents/postdoc/body.ko.md +122 -0
  11. package/{agents → assets/agents}/postdoc/body.md +16 -0
  12. package/assets/agents/researcher/body.ko.md +137 -0
  13. package/{agents → assets/agents}/researcher/body.md +15 -0
  14. package/assets/agents/reviewer/body.ko.md +138 -0
  15. package/{agents → assets/agents}/reviewer/body.md +15 -0
  16. package/assets/agents/strategist/body.ko.md +116 -0
  17. package/{agents → assets/agents}/strategist/body.md +16 -0
  18. package/assets/agents/tester/body.ko.md +195 -0
  19. package/{agents → assets/agents}/tester/body.md +15 -0
  20. package/assets/agents/writer/body.ko.md +122 -0
  21. package/{agents → assets/agents}/writer/body.md +14 -0
  22. package/assets/capability-matrix.yml +198 -0
  23. package/assets/hooks/agent-bootstrap/handler.test.ts +368 -0
  24. package/assets/hooks/agent-bootstrap/handler.ts +119 -0
  25. package/assets/hooks/agent-bootstrap/meta.yml +10 -0
  26. package/assets/hooks/agent-finalize/handler.test.ts +368 -0
  27. package/assets/hooks/agent-finalize/handler.ts +76 -0
  28. package/assets/hooks/agent-finalize/meta.yml +10 -0
  29. package/assets/hooks/capability-matrix.yml +313 -0
  30. package/assets/hooks/post-tool-telemetry/handler.test.ts +302 -0
  31. package/assets/hooks/post-tool-telemetry/handler.ts +49 -0
  32. package/assets/hooks/post-tool-telemetry/meta.yml +11 -0
  33. package/assets/hooks/prompt-router/handler.test.ts +801 -0
  34. package/assets/hooks/prompt-router/handler.ts +261 -0
  35. package/assets/hooks/prompt-router/meta.yml +11 -0
  36. package/assets/hooks/session-init/handler.test.ts +274 -0
  37. package/assets/hooks/session-init/handler.ts +30 -0
  38. package/assets/hooks/session-init/meta.yml +9 -0
  39. package/assets/lsp-servers.json +55 -0
  40. package/assets/schema/lsp-servers.schema.json +67 -0
  41. package/assets/skills/nx-init/body.ko.md +197 -0
  42. package/{skills → assets/skills}/nx-init/body.md +11 -0
  43. package/assets/skills/nx-plan/body.ko.md +361 -0
  44. package/{skills → assets/skills}/nx-plan/body.md +13 -0
  45. package/assets/skills/nx-run/body.ko.md +161 -0
  46. package/{skills → assets/skills}/nx-run/body.md +11 -0
  47. package/assets/skills/nx-sync/body.ko.md +92 -0
  48. package/{skills → assets/skills}/nx-sync/body.md +10 -0
  49. package/assets/tools/tool-name-map.yml +353 -0
  50. package/dist/hooks/opencode-mount.d.ts +35 -0
  51. package/dist/hooks/opencode-mount.d.ts.map +1 -0
  52. package/dist/hooks/opencode-mount.js +332 -0
  53. package/dist/hooks/opencode-mount.js.map +1 -0
  54. package/dist/hooks/runtime.d.ts +37 -0
  55. package/dist/hooks/runtime.d.ts.map +1 -0
  56. package/dist/hooks/runtime.js +274 -0
  57. package/dist/hooks/runtime.js.map +1 -0
  58. package/dist/hooks/types.d.ts +196 -0
  59. package/dist/hooks/types.d.ts.map +1 -0
  60. package/dist/hooks/types.js +85 -0
  61. package/dist/hooks/types.js.map +1 -0
  62. package/dist/lsp/cache.d.ts +9 -0
  63. package/dist/lsp/cache.d.ts.map +1 -0
  64. package/dist/lsp/cache.js +216 -0
  65. package/dist/lsp/cache.js.map +1 -0
  66. package/dist/lsp/client.d.ts +24 -0
  67. package/dist/lsp/client.d.ts.map +1 -0
  68. package/dist/lsp/client.js +166 -0
  69. package/dist/lsp/client.js.map +1 -0
  70. package/dist/lsp/detect.d.ts +77 -0
  71. package/dist/lsp/detect.d.ts.map +1 -0
  72. package/dist/lsp/detect.js +116 -0
  73. package/dist/lsp/detect.js.map +1 -0
  74. package/dist/mcp/server.d.ts +5 -0
  75. package/dist/mcp/server.d.ts.map +1 -0
  76. package/dist/mcp/server.js +34 -0
  77. package/dist/mcp/server.js.map +1 -0
  78. package/dist/mcp/tools/artifact.d.ts +4 -0
  79. package/dist/mcp/tools/artifact.d.ts.map +1 -0
  80. package/dist/mcp/tools/artifact.js +36 -0
  81. package/dist/mcp/tools/artifact.js.map +1 -0
  82. package/dist/mcp/tools/history.d.ts +3 -0
  83. package/dist/mcp/tools/history.d.ts.map +1 -0
  84. package/dist/mcp/tools/history.js +29 -0
  85. package/dist/mcp/tools/history.js.map +1 -0
  86. package/dist/mcp/tools/lsp.d.ts +13 -0
  87. package/dist/mcp/tools/lsp.d.ts.map +1 -0
  88. package/dist/mcp/tools/lsp.js +225 -0
  89. package/dist/mcp/tools/lsp.js.map +1 -0
  90. package/dist/mcp/tools/plan.d.ts +3 -0
  91. package/dist/mcp/tools/plan.d.ts.map +1 -0
  92. package/dist/mcp/tools/plan.js +317 -0
  93. package/dist/mcp/tools/plan.js.map +1 -0
  94. package/dist/mcp/tools/task.d.ts +3 -0
  95. package/dist/mcp/tools/task.d.ts.map +1 -0
  96. package/dist/mcp/tools/task.js +252 -0
  97. package/dist/mcp/tools/task.js.map +1 -0
  98. package/dist/shared/invocations.d.ts +74 -0
  99. package/dist/shared/invocations.d.ts.map +1 -0
  100. package/dist/shared/invocations.js +247 -0
  101. package/dist/shared/invocations.js.map +1 -0
  102. package/dist/shared/json-store.d.ts +37 -0
  103. package/dist/shared/json-store.d.ts.map +1 -0
  104. package/dist/shared/json-store.js +163 -0
  105. package/dist/shared/json-store.js.map +1 -0
  106. package/dist/shared/mcp-utils.d.ts +3 -0
  107. package/dist/shared/mcp-utils.d.ts.map +1 -0
  108. package/dist/shared/mcp-utils.js +6 -0
  109. package/dist/shared/mcp-utils.js.map +1 -0
  110. package/dist/shared/paths.d.ts +21 -0
  111. package/dist/shared/paths.d.ts.map +1 -0
  112. package/dist/shared/paths.js +81 -0
  113. package/dist/shared/paths.js.map +1 -0
  114. package/dist/shared/tool-log.d.ts +8 -0
  115. package/dist/shared/tool-log.d.ts.map +1 -0
  116. package/dist/shared/tool-log.js +22 -0
  117. package/dist/shared/tool-log.js.map +1 -0
  118. package/dist/types/state.d.ts +862 -0
  119. package/dist/types/state.d.ts.map +1 -0
  120. package/dist/types/state.js +66 -0
  121. package/dist/types/state.js.map +1 -0
  122. package/docs/consuming/codex-lead-merge.md +106 -0
  123. package/docs/plugin-guide.md +360 -0
  124. package/docs/plugin-template/claude/.github/workflows/build.yml +60 -0
  125. package/docs/plugin-template/claude/README.md +110 -0
  126. package/docs/plugin-template/claude/package.json +16 -0
  127. package/docs/plugin-template/codex/.github/workflows/build.yml +51 -0
  128. package/docs/plugin-template/codex/README.md +147 -0
  129. package/docs/plugin-template/codex/package.json +17 -0
  130. package/docs/plugin-template/opencode/.github/workflows/build.yml +61 -0
  131. package/docs/plugin-template/opencode/README.md +121 -0
  132. package/docs/plugin-template/opencode/package.json +25 -0
  133. package/package.json +21 -21
  134. package/scripts/build-agents.test.ts +1279 -0
  135. package/scripts/build-agents.ts +978 -0
  136. package/scripts/build-hooks.test.ts +1385 -0
  137. package/scripts/build-hooks.ts +584 -0
  138. package/scripts/cli.test.ts +367 -0
  139. package/scripts/cli.ts +547 -0
  140. package/agents/architect/meta.yml +0 -13
  141. package/agents/designer/meta.yml +0 -13
  142. package/agents/engineer/meta.yml +0 -11
  143. package/agents/postdoc/meta.yml +0 -13
  144. package/agents/researcher/meta.yml +0 -12
  145. package/agents/reviewer/meta.yml +0 -12
  146. package/agents/strategist/meta.yml +0 -13
  147. package/agents/tester/meta.yml +0 -12
  148. package/agents/writer/meta.yml +0 -11
  149. package/conformance/README.md +0 -311
  150. package/conformance/examples/plan.extension.schema.example.json +0 -25
  151. package/conformance/lifecycle/README.md +0 -48
  152. package/conformance/lifecycle/agent-complete.json +0 -44
  153. package/conformance/lifecycle/agent-resume.json +0 -43
  154. package/conformance/lifecycle/agent-spawn.json +0 -36
  155. package/conformance/lifecycle/memory-access-record.json +0 -27
  156. package/conformance/lifecycle/session-end.json +0 -48
  157. package/conformance/scenarios/full-plan-cycle.json +0 -147
  158. package/conformance/scenarios/task-deps-ordering.json +0 -95
  159. package/conformance/schema/fixture.schema.json +0 -354
  160. package/conformance/state-schemas/agent-tracker.schema.json +0 -63
  161. package/conformance/state-schemas/history.schema.json +0 -134
  162. package/conformance/state-schemas/memory-access.schema.json +0 -36
  163. package/conformance/state-schemas/plan.schema.json +0 -77
  164. package/conformance/state-schemas/tasks.schema.json +0 -98
  165. package/conformance/tools/artifact-write.json +0 -97
  166. package/conformance/tools/context.json +0 -172
  167. package/conformance/tools/history-search.json +0 -219
  168. package/conformance/tools/plan-decide.json +0 -139
  169. package/conformance/tools/plan-start.json +0 -81
  170. package/conformance/tools/plan-status.json +0 -127
  171. package/conformance/tools/plan-update.json +0 -341
  172. package/conformance/tools/task-add.json +0 -156
  173. package/conformance/tools/task-close.json +0 -161
  174. package/conformance/tools/task-list.json +0 -177
  175. package/conformance/tools/task-update.json +0 -167
  176. package/docs/behavioral-contracts.md +0 -145
  177. package/docs/consumer-implementation-guide.md +0 -852
  178. package/docs/memory-lifecycle-contract.md +0 -119
  179. package/docs/nexus-layout.md +0 -224
  180. package/docs/nexus-outputs-contract.md +0 -344
  181. package/docs/nexus-state-overview.md +0 -170
  182. package/docs/nexus-tools-contract.md +0 -438
  183. package/manifest.json +0 -449
  184. package/schema/README.md +0 -69
  185. package/schema/agent.schema.json +0 -23
  186. package/schema/common.schema.json +0 -17
  187. package/schema/manifest.schema.json +0 -78
  188. package/schema/memory-policy.schema.json +0 -98
  189. package/schema/skill.schema.json +0 -54
  190. package/schema/task-exceptions.schema.json +0 -40
  191. package/schema/vocabulary.schema.json +0 -167
  192. package/scripts/.gitkeep +0 -0
  193. package/scripts/conformance-coverage.ts +0 -466
  194. package/scripts/import-from-claude-nexus.ts +0 -403
  195. package/scripts/lib/frontmatter.ts +0 -71
  196. package/scripts/lib/lint.ts +0 -348
  197. package/scripts/lib/structure.ts +0 -159
  198. package/scripts/lib/validate.ts +0 -794
  199. package/scripts/validate.ts +0 -90
  200. package/skills/nx-init/meta.yml +0 -8
  201. package/skills/nx-plan/meta.yml +0 -10
  202. package/skills/nx-run/meta.yml +0 -9
  203. package/skills/nx-sync/meta.yml +0 -7
  204. package/vocabulary/capabilities.yml +0 -65
  205. package/vocabulary/categories.yml +0 -11
  206. package/vocabulary/invocations.yml +0 -147
  207. package/vocabulary/memory_policy.yml +0 -88
  208. package/vocabulary/resume-tiers.yml +0 -11
  209. package/vocabulary/tags.yml +0 -60
  210. package/vocabulary/task-exceptions.yml +0 -29
@@ -0,0 +1,1385 @@
1
+ /**
2
+ * scripts/build-hooks.test.ts
3
+ *
4
+ * Verifies build-hooks.ts against isolated tmp-dir fixtures.
5
+ * Tests run by spawning a dynamically-generated wrapper script that
6
+ * overrides ROOT / HOOKS_DIR / DIST to the tmp fixture directory,
7
+ * so the real assets/ directory is never touched.
8
+ *
9
+ * 8 scenarios:
10
+ * (1) 정상 5 hook 빌드 → 3 manifest 유효 JSON 생성
11
+ * (2) portability-report.json 생성 — {tier, registered_in, excluded_from, capabilities_required}
12
+ * (3) post-tool-telemetry Codex에서 excluded (event.post_tool_use.edit false) → partial tier 기록
13
+ * (4) meta.yml에 portability_tier 명시 → 빌드 실패 (zod strict)
14
+ * (5) 존재하지 않는 capability ID → 빌드 실패
15
+ * (6) fallback=error + 미지원 harness → 빌드 실패
16
+ * (7) matcher tool-name alias 변환 확인 (Bash→shell/bash/Bash)
17
+ * (8) priority 정렬 확인
18
+ */
19
+
20
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
21
+ import {
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ readFileSync,
27
+ existsSync,
28
+ statSync,
29
+ } from "node:fs";
30
+ import { join, resolve } from "node:path";
31
+ import { tmpdir } from "node:os";
32
+ import { spawnSync } from "node:child_process";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const REPO_ROOT = resolve(fileURLToPath(import.meta.url), "../..");
40
+
41
+ /**
42
+ * Create a self-contained fixture directory that looks like:
43
+ *
44
+ * <tmp>/
45
+ * assets/
46
+ * hooks/
47
+ * capability-matrix.yml
48
+ * <hookName>/
49
+ * meta.yml
50
+ * handler.ts
51
+ * tools/
52
+ * tool-name-map.yml
53
+ * dist/ (created by build script)
54
+ */
55
+ function createFixtureDir(
56
+ hooks: Array<{ name: string; meta: string; handler?: string }>,
57
+ capabilityMatrix: string,
58
+ toolNameMap?: string,
59
+ ): string {
60
+ const tmp = mkdtempSync(join(tmpdir(), "build-hooks-test-"));
61
+
62
+ // capability-matrix.yml
63
+ const hooksDir = join(tmp, "assets", "hooks");
64
+ mkdirSync(hooksDir, { recursive: true });
65
+ writeFileSync(join(hooksDir, "capability-matrix.yml"), capabilityMatrix);
66
+
67
+ // tool-name-map.yml
68
+ const toolsDir = join(tmp, "assets", "tools");
69
+ mkdirSync(toolsDir, { recursive: true });
70
+ writeFileSync(join(toolsDir, "tool-name-map.yml"), toolNameMap ?? defaultToolNameMap());
71
+
72
+ // hook dirs
73
+ for (const hook of hooks) {
74
+ const dir = join(hooksDir, hook.name);
75
+ mkdirSync(dir, { recursive: true });
76
+ writeFileSync(join(dir, "meta.yml"), hook.meta);
77
+ writeFileSync(
78
+ join(dir, "handler.ts"),
79
+ hook.handler ?? minimalHandler(),
80
+ );
81
+ }
82
+
83
+ return tmp;
84
+ }
85
+
86
+ function minimalHandler(): string {
87
+ return `export default async function handler(_input: unknown) { return; }\n`;
88
+ }
89
+
90
+ function defaultToolNameMap(): string {
91
+ return `tools:
92
+ Bash:
93
+ claude: Bash
94
+ codex:
95
+ primary: shell
96
+ aliases: [shell_command]
97
+ opencode: bash
98
+ Read:
99
+ claude: Read
100
+ codex: null
101
+ opencode: read
102
+ Edit:
103
+ claude: Edit
104
+ codex: apply_patch
105
+ opencode: edit
106
+ Write:
107
+ claude: Write
108
+ codex: apply_patch
109
+ opencode: write
110
+ MultiEdit:
111
+ claude: MultiEdit
112
+ codex: apply_patch
113
+ opencode: edit
114
+ ApplyPatch:
115
+ claude: [Edit, MultiEdit]
116
+ codex: apply_patch
117
+ opencode: [apply_patch, edit]
118
+ `;
119
+ }
120
+
121
+ function minimalCapabilityMatrix(extras: string = ""): string {
122
+ return `capabilities:
123
+ event.session_start:
124
+ claude: true
125
+ codex: true
126
+ opencode: true
127
+ event.user_prompt_submit:
128
+ claude: true
129
+ codex: true
130
+ opencode: true
131
+ event.pre_tool_use.bash:
132
+ claude: true
133
+ codex: true
134
+ opencode: true
135
+ event.post_tool_use.bash:
136
+ claude: true
137
+ codex: true
138
+ opencode: true
139
+ event.post_tool_use.read:
140
+ claude: true
141
+ codex: false
142
+ opencode: true
143
+ note: "Codex has no read tool"
144
+ event.post_tool_use.edit:
145
+ claude: true
146
+ codex: false
147
+ opencode: true
148
+ note: "Codex apply_patch does not emit PostToolUse"
149
+ event.post_tool_use.bash_parsed:
150
+ claude: false
151
+ codex: true
152
+ opencode: false
153
+ note: "Codex-specific bash parsing"
154
+ event.subagent_start:
155
+ claude: true
156
+ codex: true
157
+ opencode: true
158
+ event.subagent_stop:
159
+ claude: true
160
+ codex: true
161
+ opencode: true
162
+ output.additional_context.session_start:
163
+ claude: true
164
+ codex: true
165
+ opencode: true
166
+ output.additional_context.user_prompt:
167
+ claude: true
168
+ codex: true
169
+ opencode: true
170
+ output.additional_context.subagent_stop:
171
+ claude: true
172
+ codex: true
173
+ opencode: true
174
+ output.additional_context.post_tool:
175
+ claude: true
176
+ codex: true
177
+ opencode: false
178
+ note: "OpenCode PostToolUse context injection not adopted"
179
+ output.decision_block:
180
+ claude: true
181
+ codex: true
182
+ opencode: true
183
+ ${extras}`;
184
+ }
185
+
186
+ /**
187
+ * Build a wrapper script that:
188
+ * 1. Imports the internal build-hooks.ts functions (re-exporting them)
189
+ * 2. Overrides the module-level constants with tmp-dir paths
190
+ * 3. Runs buildHooks() from the overridden context
191
+ *
192
+ * Strategy: We write a standalone script that duplicates the build logic
193
+ * pointing at the fixture directory. The script is written to REPO_ROOT/scripts/
194
+ * so that relative imports (../src/hooks/types.js) resolve correctly.
195
+ * The fixture paths are passed as env vars.
196
+ */
197
+ function writeBuildWrapper(wrapperPath: string, fixtureRoot: string): void {
198
+ const src = `
199
+ // Auto-generated test wrapper — do not commit
200
+ import { HookMetaSchema } from "../src/hooks/types.js";
201
+ import type { HookMeta } from "../src/hooks/types.js";
202
+ import {
203
+ readFileSync,
204
+ readdirSync,
205
+ existsSync,
206
+ mkdirSync,
207
+ writeFileSync,
208
+ } from "node:fs";
209
+ import { join, resolve } from "node:path";
210
+ import { execSync } from "node:child_process";
211
+ import { parse as parseYaml } from "yaml";
212
+
213
+ // ── Overridden paths from env ──────────────────────────────────────────────
214
+ const ROOT = process.env.NEXUS_TEST_ROOT ?? ${JSON.stringify(fixtureRoot)};
215
+ const HOOKS_DIR = join(ROOT, "assets/hooks");
216
+ const CAPABILITY_MATRIX_PATH = join(HOOKS_DIR, "capability-matrix.yml");
217
+ const TOOL_NAME_MAP_PATH = join(ROOT, "assets/tools/tool-name-map.yml");
218
+ const DIST_HOOKS_DIR = join(ROOT, "dist/hooks");
219
+ const DIST_MANIFESTS_DIR = join(ROOT, "dist/manifests");
220
+
221
+ const HARNESSES = ["claude", "codex", "opencode"] as const;
222
+ type Harness = (typeof HARNESSES)[number];
223
+ type CapabilityValue = boolean | "partial";
224
+
225
+ interface HookEntry {
226
+ name: string;
227
+ meta: HookMeta;
228
+ handlerPath: string;
229
+ }
230
+
231
+ interface CapabilityMatrix {
232
+ capabilities: Record<
233
+ string,
234
+ { claude: CapabilityValue; codex: CapabilityValue; opencode: CapabilityValue; note?: string }
235
+ >;
236
+ }
237
+
238
+ interface ExclusionRecord {
239
+ harness: Harness;
240
+ missing: string[];
241
+ reason: string;
242
+ }
243
+
244
+ interface PortabilityPlan {
245
+ name: string;
246
+ meta: HookMeta;
247
+ tier: "core" | "extended" | "experimental" | "harness-specific";
248
+ registeredIn: Harness[];
249
+ excludedFrom: ExclusionRecord[];
250
+ capabilitiesRequired: string[];
251
+ }
252
+
253
+ interface ToolNameMap {
254
+ tools: Record<
255
+ string,
256
+ {
257
+ claude: string | string[] | null;
258
+ codex: string | string[] | null | { primary: string; aliases: string[] };
259
+ opencode: string | string[] | null;
260
+ }
261
+ >;
262
+ }
263
+
264
+ function loadAllHooks(): HookEntry[] {
265
+ const entries = readdirSync(HOOKS_DIR, { withFileTypes: true });
266
+ const result: HookEntry[] = [];
267
+ for (const entry of entries) {
268
+ if (!entry.isDirectory()) continue;
269
+ const metaPath = join(HOOKS_DIR, entry.name, "meta.yml");
270
+ const handlerPath = join(HOOKS_DIR, entry.name, "handler.ts");
271
+ if (!existsSync(metaPath) || !existsSync(handlerPath)) continue;
272
+ const metaRaw = parseYaml(readFileSync(metaPath, "utf-8"));
273
+ let meta: HookMeta;
274
+ try {
275
+ meta = HookMetaSchema.parse(metaRaw);
276
+ } catch (err) {
277
+ throw new Error(
278
+ \`[build-hooks] meta.yml validation failed for "\${entry.name}": \${String(err)}\`,
279
+ );
280
+ }
281
+ result.push({ name: entry.name, meta, handlerPath });
282
+ }
283
+ result.sort((a, b) => b.meta.priority - a.meta.priority);
284
+ return result;
285
+ }
286
+
287
+ function loadCapabilityMatrix(): CapabilityMatrix {
288
+ const raw = readFileSync(CAPABILITY_MATRIX_PATH, "utf-8");
289
+ return parseYaml(raw) as CapabilityMatrix;
290
+ }
291
+
292
+ function validateCapabilityIds(hooks: HookEntry[], matrix: CapabilityMatrix): void {
293
+ const knownIds = new Set(Object.keys(matrix.capabilities));
294
+ for (const hook of hooks) {
295
+ for (const capId of hook.meta.requires_capabilities) {
296
+ if (!knownIds.has(capId)) {
297
+ throw new Error(
298
+ \`[build-hooks] "\${hook.name}" requires unknown capability "\${capId}". \` +
299
+ \`Known IDs: \${[...knownIds].join(", ")}\`,
300
+ );
301
+ }
302
+ }
303
+ }
304
+ }
305
+
306
+ const CAP_EVENT_MAP = [
307
+ { prefix: "event.session_start", events: ["SessionStart"] },
308
+ { prefix: "event.user_prompt_submit", events: ["UserPromptSubmit"] },
309
+ { prefix: "event.pre_tool_use", events: ["PreToolUse"] },
310
+ { prefix: "event.post_tool_use", events: ["PostToolUse"] },
311
+ { prefix: "event.subagent_start", events: ["SubagentStart"] },
312
+ { prefix: "event.subagent_stop", events: ["SubagentStop"] },
313
+ ];
314
+
315
+ function warnOnMismatch(hooks: HookEntry[]): void {
316
+ for (const hook of hooks) {
317
+ const hookEvents = new Set(hook.meta.events);
318
+ for (const capId of hook.meta.requires_capabilities) {
319
+ for (const mapping of CAP_EVENT_MAP) {
320
+ if (!capId.startsWith(mapping.prefix)) continue;
321
+ const overlap = mapping.events.some((e) => hookEvents.has(e as never));
322
+ if (!overlap) {
323
+ process.stderr.write(
324
+ \`[build-hooks] WARN mismatch: hook "\${hook.name}" listens on [\${hook.meta.events.join(", ")}] \` +
325
+ \`but capability "\${capId}" applies to [\${mapping.events.join(", ")}]\\n\`,
326
+ );
327
+ }
328
+ }
329
+ }
330
+ }
331
+ }
332
+
333
+ function computePortability(hooks: HookEntry[], matrix: CapabilityMatrix): PortabilityPlan[] {
334
+ const plans: PortabilityPlan[] = [];
335
+ for (const hook of hooks) {
336
+ const registeredIn: Harness[] = [];
337
+ const excludedFrom: ExclusionRecord[] = [];
338
+ for (const harness of HARNESSES) {
339
+ const missingCaps: string[] = [];
340
+ for (const capId of hook.meta.requires_capabilities) {
341
+ const capEntry = matrix.capabilities[capId];
342
+ if (!capEntry) continue;
343
+ const support = capEntry[harness];
344
+ if (support !== true) {
345
+ missingCaps.push(capId);
346
+ }
347
+ }
348
+ if (missingCaps.length === 0) {
349
+ registeredIn.push(harness);
350
+ } else {
351
+ const reasons = missingCaps
352
+ .map((capId) => {
353
+ const entry = matrix.capabilities[capId];
354
+ if (!entry?.note) return capId;
355
+ const note = entry.note.trim().split(/\\.\\s/)[0]?.trim() ?? capId;
356
+ return note;
357
+ })
358
+ .join("; ");
359
+ if (hook.meta.fallback === "error") {
360
+ throw new Error(
361
+ \`[build-hooks] Hook "\${hook.name}" fallback=error but harness "\${harness}" \` +
362
+ \`is missing capabilities: \${missingCaps.join(", ")}\`,
363
+ );
364
+ }
365
+ if (hook.meta.fallback === "warn") {
366
+ process.stderr.write(
367
+ \`[build-hooks] WARN: hook "\${hook.name}" excluded from "\${harness}" \` +
368
+ \`(missing: \${missingCaps.join(", ")})\\n\`,
369
+ );
370
+ }
371
+ excludedFrom.push({ harness, missing: missingCaps, reason: reasons });
372
+ }
373
+ }
374
+ const tier = deriveTier(registeredIn, hook.meta.fallback);
375
+ plans.push({
376
+ name: hook.name,
377
+ meta: hook.meta,
378
+ tier,
379
+ registeredIn,
380
+ excludedFrom,
381
+ capabilitiesRequired: hook.meta.requires_capabilities,
382
+ });
383
+ }
384
+ return plans;
385
+ }
386
+
387
+ function deriveTier(
388
+ registeredIn: Harness[],
389
+ fallback: HookMeta["fallback"],
390
+ ): PortabilityPlan["tier"] {
391
+ const count = registeredIn.length;
392
+ if (count === 3) return "core";
393
+ if (count === 2) return "extended";
394
+ if (count === 1) {
395
+ if (fallback === "skip") return "harness-specific";
396
+ return "experimental";
397
+ }
398
+ return "experimental";
399
+ }
400
+
401
+ function compileHandlers(hooks: HookEntry[]): void {
402
+ mkdirSync(DIST_HOOKS_DIR, { recursive: true });
403
+ for (const hook of hooks) {
404
+ const outFile = join(DIST_HOOKS_DIR, \`\${hook.name}.js\`);
405
+ try {
406
+ execSync(
407
+ \`bun build \${hook.handlerPath} --outfile \${outFile} --target node --format esm\`,
408
+ { cwd: ROOT, stdio: "inherit" },
409
+ );
410
+ } catch {
411
+ throw new Error(
412
+ \`[build-hooks] Handler compilation failed for "\${hook.name}" (bun build exit non-zero)\`,
413
+ );
414
+ }
415
+ }
416
+ }
417
+
418
+ function loadToolNameMap(): ToolNameMap {
419
+ const raw = readFileSync(TOOL_NAME_MAP_PATH, "utf-8");
420
+ return parseYaml(raw) as ToolNameMap;
421
+ }
422
+
423
+ function translateMatcherToken(token: string, harness: Harness, toolMap: ToolNameMap): string {
424
+ const entry = toolMap.tools[token];
425
+ if (!entry) return token;
426
+ const harnessValue = entry[harness];
427
+ if (harnessValue === null || harnessValue === undefined) return token;
428
+ if (typeof harnessValue === "string") return harnessValue;
429
+ if (Array.isArray(harnessValue)) {
430
+ return harnessValue.join("|");
431
+ }
432
+ if (typeof harnessValue === "object" && "primary" in harnessValue) {
433
+ return (harnessValue as { primary: string }).primary;
434
+ }
435
+ return token;
436
+ }
437
+
438
+ function translateMatcher(matcher: string, harness: Harness, toolMap: ToolNameMap): string {
439
+ if (matcher === "*") return "*";
440
+ const tokens = matcher.split("|").map((t) => t.trim());
441
+ const translated = new Set<string>();
442
+ for (const token of tokens) {
443
+ const native = translateMatcherToken(token, harness, toolMap);
444
+ for (const part of native.split("|")) {
445
+ if (part.trim()) translated.add(part.trim());
446
+ }
447
+ }
448
+ return [...translated].join("|");
449
+ }
450
+
451
+ function hookCommand(hookName: string, harness: Harness): string {
452
+ if (harness === "opencode") {
453
+ return hookName;
454
+ }
455
+ return \`node \\\${CLAUDE_PLUGIN_ROOT}/dist/hooks/\${hookName}.js\`;
456
+ }
457
+
458
+ type ClaudeHooksJson = {
459
+ hooks: Record<
460
+ string,
461
+ Array<{ matcher: string; hooks: Array<{ type: string; command: string; timeout: number }> }>
462
+ >;
463
+ };
464
+
465
+ function buildClaudeManifest(plans: PortabilityPlan[], toolMap: ToolNameMap): ClaudeHooksJson {
466
+ const hooks: ClaudeHooksJson["hooks"] = {};
467
+ for (const plan of plans) {
468
+ if (!plan.registeredIn.includes("claude")) continue;
469
+ for (const event of plan.meta.events) {
470
+ if (!hooks[event]) hooks[event] = [];
471
+ const matcher = translateMatcher(plan.meta.matcher, "claude", toolMap);
472
+ hooks[event].push({
473
+ matcher,
474
+ hooks: [
475
+ {
476
+ type: "command",
477
+ command: hookCommand(plan.name, "claude"),
478
+ timeout: plan.meta.timeout,
479
+ },
480
+ ],
481
+ });
482
+ }
483
+ }
484
+ return { hooks };
485
+ }
486
+
487
+ type CodexHooksJson = {
488
+ hooks: Record<
489
+ string,
490
+ Array<{ matcher?: string; command: string; timeout: number }>
491
+ >;
492
+ };
493
+
494
+ function buildCodexManifest(plans: PortabilityPlan[], toolMap: ToolNameMap): CodexHooksJson {
495
+ const hooks: CodexHooksJson["hooks"] = {};
496
+ for (const plan of plans) {
497
+ if (!plan.registeredIn.includes("codex")) continue;
498
+ for (const event of plan.meta.events) {
499
+ if (!hooks[event]) hooks[event] = [];
500
+ const matcher = translateMatcher(plan.meta.matcher, "codex", toolMap);
501
+ const entry: { matcher?: string; command: string; timeout: number } = {
502
+ command: hookCommand(plan.name, "codex"),
503
+ timeout: plan.meta.timeout,
504
+ };
505
+ if (matcher !== "*") entry.matcher = matcher;
506
+ hooks[event].push(entry);
507
+ }
508
+ }
509
+ return { hooks };
510
+ }
511
+
512
+ type OpenCodeHooksJson = {
513
+ mountHooks: Array<{
514
+ event: string;
515
+ matcher?: string;
516
+ module: string;
517
+ timeout: number;
518
+ }>;
519
+ };
520
+
521
+ function buildOpenCodeManifest(plans: PortabilityPlan[], toolMap: ToolNameMap): OpenCodeHooksJson {
522
+ const mountHooks: OpenCodeHooksJson["mountHooks"] = [];
523
+ for (const plan of plans) {
524
+ if (!plan.registeredIn.includes("opencode")) continue;
525
+ for (const event of plan.meta.events) {
526
+ const matcher = translateMatcher(plan.meta.matcher, "opencode", toolMap);
527
+ const entry: OpenCodeHooksJson["mountHooks"][number] = {
528
+ event,
529
+ module: \`./dist/hooks/\${plan.name}.js\`,
530
+ timeout: plan.meta.timeout,
531
+ };
532
+ if (matcher !== "*") entry.matcher = matcher;
533
+ mountHooks.push(entry);
534
+ }
535
+ }
536
+ return { mountHooks };
537
+ }
538
+
539
+ function writeManifests(plans: PortabilityPlan[]): void {
540
+ mkdirSync(DIST_MANIFESTS_DIR, { recursive: true });
541
+ const toolMap = loadToolNameMap();
542
+ const claude = buildClaudeManifest(plans, toolMap);
543
+ const codex = buildCodexManifest(plans, toolMap);
544
+ const opencode = buildOpenCodeManifest(plans, toolMap);
545
+ writeFileSync(
546
+ join(DIST_MANIFESTS_DIR, "claude-hooks.json"),
547
+ JSON.stringify(claude, null, 2) + "\\n",
548
+ );
549
+ writeFileSync(
550
+ join(DIST_MANIFESTS_DIR, "codex-hooks.json"),
551
+ JSON.stringify(codex, null, 2) + "\\n",
552
+ );
553
+ writeFileSync(
554
+ join(DIST_MANIFESTS_DIR, "opencode-hooks.json"),
555
+ JSON.stringify(opencode, null, 2) + "\\n",
556
+ );
557
+ }
558
+
559
+ type PortabilityReport = Record<
560
+ string,
561
+ {
562
+ tier: PortabilityPlan["tier"];
563
+ registered_in: Harness[];
564
+ excluded_from: Array<{ harness: Harness; missing: string[]; reason: string }>;
565
+ capabilities_required: string[];
566
+ }
567
+ >;
568
+
569
+ function writePortabilityReport(plans: PortabilityPlan[]): void {
570
+ mkdirSync(DIST_MANIFESTS_DIR, { recursive: true });
571
+ const report: PortabilityReport = {};
572
+ for (const plan of plans) {
573
+ report[plan.name] = {
574
+ tier: plan.tier,
575
+ registered_in: plan.registeredIn,
576
+ excluded_from: plan.excludedFrom,
577
+ capabilities_required: plan.capabilitiesRequired,
578
+ };
579
+ }
580
+ writeFileSync(
581
+ join(DIST_MANIFESTS_DIR, "portability-report.json"),
582
+ JSON.stringify(report, null, 2) + "\\n",
583
+ );
584
+ }
585
+
586
+ // ── Entry point (called by test wrapper) ───────────────────────────────────
587
+ const hooks = loadAllHooks();
588
+ const matrix = loadCapabilityMatrix();
589
+ validateCapabilityIds(hooks, matrix);
590
+ warnOnMismatch(hooks);
591
+ const plans = computePortability(hooks, matrix);
592
+ compileHandlers(hooks);
593
+ writeManifests(plans);
594
+ writePortabilityReport(plans);
595
+ process.stdout.write("[build-hooks-wrapper] done\\n");
596
+ `;
597
+ writeFileSync(wrapperPath, src);
598
+ }
599
+
600
+ /**
601
+ * Run the build wrapper in the given fixture dir.
602
+ * Returns { exitCode, stdout, stderr }.
603
+ */
604
+ function runBuild(
605
+ fixtureRoot: string,
606
+ wrapperPath: string,
607
+ ): { exitCode: number | null; stdout: string; stderr: string } {
608
+ const result = spawnSync("bun", ["run", wrapperPath], {
609
+ cwd: fixtureRoot,
610
+ env: { ...process.env, NEXUS_TEST_ROOT: fixtureRoot },
611
+ encoding: "utf-8",
612
+ timeout: 60_000,
613
+ });
614
+ return {
615
+ exitCode: result.status,
616
+ stdout: result.stdout ?? "",
617
+ stderr: result.stderr ?? "",
618
+ };
619
+ }
620
+
621
+ // ---------------------------------------------------------------------------
622
+ // Fixtures
623
+ // ---------------------------------------------------------------------------
624
+
625
+ /** 5-hook fixture mirroring the real assets — all capabilities available */
626
+ function fiveHookFixture() {
627
+ const hooks = [
628
+ {
629
+ name: "session-init",
630
+ meta: `name: session-init
631
+ description: Initialize per-session state files at session start
632
+ events: [SessionStart]
633
+ matcher: "*"
634
+ timeout: 10
635
+ fallback: warn
636
+ priority: 0
637
+ requires_capabilities:
638
+ - event.session_start
639
+ `,
640
+ },
641
+ {
642
+ name: "prompt-router",
643
+ meta: `name: prompt-router
644
+ description: Detect nexus tags, inject state notices and skill invocation guidance
645
+ events: [UserPromptSubmit]
646
+ matcher: "*"
647
+ timeout: 10
648
+ fallback: warn
649
+ priority: 0
650
+ requires_capabilities:
651
+ - event.user_prompt_submit
652
+ - output.additional_context.user_prompt
653
+ - output.decision_block
654
+ `,
655
+ },
656
+ {
657
+ name: "agent-bootstrap",
658
+ meta: `name: agent-bootstrap
659
+ description: Inject core memory index and role-specific rules on fresh subagent spawn
660
+ events: [SubagentStart]
661
+ matcher: "*"
662
+ timeout: 10
663
+ fallback: warn
664
+ priority: 0
665
+ requires_capabilities:
666
+ - event.subagent_start
667
+ - output.additional_context.session_start
668
+ `,
669
+ },
670
+ {
671
+ name: "agent-finalize",
672
+ meta: `name: agent-finalize
673
+ description: Finalize subagent tracker, aggregate files_touched
674
+ events: [SubagentStop]
675
+ matcher: "*"
676
+ timeout: 10
677
+ fallback: warn
678
+ priority: 0
679
+ requires_capabilities:
680
+ - event.subagent_stop
681
+ - output.additional_context.subagent_stop
682
+ `,
683
+ },
684
+ {
685
+ name: "post-tool-telemetry",
686
+ meta: `name: post-tool-telemetry
687
+ description: Track memory access and file-edit operations for telemetry
688
+ events: [PostToolUse]
689
+ matcher: "Read|Edit|Write|MultiEdit|ApplyPatch|Bash"
690
+ timeout: 5
691
+ fallback: warn
692
+ priority: 10
693
+ requires_capabilities:
694
+ - event.post_tool_use.read
695
+ - event.post_tool_use.edit
696
+ - event.post_tool_use.bash_parsed
697
+ `,
698
+ },
699
+ ];
700
+ return hooks;
701
+ }
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // Test lifecycle
705
+ // ---------------------------------------------------------------------------
706
+
707
+ let tmpDirs: string[] = [];
708
+ let wrapperPaths: string[] = [];
709
+
710
+ afterEach(() => {
711
+ // Clean up tmp fixture dirs
712
+ for (const dir of tmpDirs) {
713
+ try {
714
+ rmSync(dir, { recursive: true, force: true });
715
+ } catch {
716
+ // best effort
717
+ }
718
+ }
719
+ tmpDirs = [];
720
+
721
+ // Clean up temp wrappers written to scripts/
722
+ for (const wp of wrapperPaths) {
723
+ try {
724
+ rmSync(wp, { force: true });
725
+ } catch {
726
+ // best effort
727
+ }
728
+ }
729
+ wrapperPaths = [];
730
+ });
731
+
732
+ function makeTmpAndWrapper(
733
+ hooks: Array<{ name: string; meta: string; handler?: string }>,
734
+ capabilityMatrix: string,
735
+ toolNameMap?: string,
736
+ ): { fixtureRoot: string; wrapperPath: string } {
737
+ const fixtureRoot = createFixtureDir(hooks, capabilityMatrix, toolNameMap);
738
+ tmpDirs.push(fixtureRoot);
739
+
740
+ // Write wrapper adjacent to scripts/build-hooks.ts so relative imports resolve
741
+ const wrapperName = `_test-wrapper-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`;
742
+ const wrapperPath = join(REPO_ROOT, "scripts", wrapperName);
743
+ writeBuildWrapper(wrapperPath, fixtureRoot);
744
+ wrapperPaths.push(wrapperPath);
745
+
746
+ return { fixtureRoot, wrapperPath };
747
+ }
748
+
749
+ // ---------------------------------------------------------------------------
750
+ // Scenario 1: 정상 5 hook 빌드 → 3 manifest 유효 JSON 생성
751
+ // ---------------------------------------------------------------------------
752
+
753
+ describe("Scenario 1 — 정상 5 hook 빌드, 3 manifest 유효 JSON", () => {
754
+ test("build exits 0 and creates 3 manifest files", () => {
755
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
756
+ fiveHookFixture(),
757
+ minimalCapabilityMatrix(),
758
+ );
759
+
760
+ const { exitCode, stderr } = runBuild(fixtureRoot, wrapperPath);
761
+ if (exitCode !== 0) {
762
+ console.error("Build stderr:", stderr);
763
+ }
764
+ expect(exitCode).toBe(0);
765
+
766
+ const manifestDir = join(fixtureRoot, "dist", "manifests");
767
+ expect(existsSync(join(manifestDir, "claude-hooks.json"))).toBe(true);
768
+ expect(existsSync(join(manifestDir, "codex-hooks.json"))).toBe(true);
769
+ expect(existsSync(join(manifestDir, "opencode-hooks.json"))).toBe(true);
770
+ });
771
+
772
+ test("all 3 manifest files are valid JSON", () => {
773
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
774
+ fiveHookFixture(),
775
+ minimalCapabilityMatrix(),
776
+ );
777
+
778
+ runBuild(fixtureRoot, wrapperPath);
779
+
780
+ const manifestDir = join(fixtureRoot, "dist", "manifests");
781
+
782
+ for (const filename of ["claude-hooks.json", "codex-hooks.json", "opencode-hooks.json"]) {
783
+ const raw = readFileSync(join(manifestDir, filename), "utf-8");
784
+ expect(() => JSON.parse(raw)).not.toThrow();
785
+ }
786
+ });
787
+
788
+ test("dist/hooks/ contains a compiled .js file for each hook", () => {
789
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
790
+ fiveHookFixture(),
791
+ minimalCapabilityMatrix(),
792
+ );
793
+
794
+ runBuild(fixtureRoot, wrapperPath);
795
+
796
+ const hooksDistDir = join(fixtureRoot, "dist", "hooks");
797
+ for (const hook of fiveHookFixture()) {
798
+ expect(existsSync(join(hooksDistDir, `${hook.name}.js`))).toBe(true);
799
+ }
800
+ });
801
+ });
802
+
803
+ // ---------------------------------------------------------------------------
804
+ // Scenario 2: portability-report.json 생성 — 필드 검증
805
+ // ---------------------------------------------------------------------------
806
+
807
+ describe("Scenario 2 — portability-report.json 구조 검증", () => {
808
+ test("portability-report.json exists and is valid JSON", () => {
809
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
810
+ fiveHookFixture(),
811
+ minimalCapabilityMatrix(),
812
+ );
813
+
814
+ runBuild(fixtureRoot, wrapperPath);
815
+
816
+ const reportPath = join(fixtureRoot, "dist", "manifests", "portability-report.json");
817
+ expect(existsSync(reportPath)).toBe(true);
818
+ const raw = readFileSync(reportPath, "utf-8");
819
+ expect(() => JSON.parse(raw)).not.toThrow();
820
+ });
821
+
822
+ test("each hook entry has required portability fields", () => {
823
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
824
+ fiveHookFixture(),
825
+ minimalCapabilityMatrix(),
826
+ );
827
+
828
+ runBuild(fixtureRoot, wrapperPath);
829
+
830
+ const reportPath = join(fixtureRoot, "dist", "manifests", "portability-report.json");
831
+ const report = JSON.parse(readFileSync(reportPath, "utf-8")) as Record<string, unknown>;
832
+
833
+ for (const hook of fiveHookFixture()) {
834
+ const entry = report[hook.name] as Record<string, unknown> | undefined;
835
+ expect(entry).toBeDefined();
836
+ expect(typeof (entry as Record<string, unknown>)?.tier).toBe("string");
837
+ expect(Array.isArray((entry as Record<string, unknown>)?.registered_in)).toBe(true);
838
+ expect(Array.isArray((entry as Record<string, unknown>)?.excluded_from)).toBe(true);
839
+ expect(Array.isArray((entry as Record<string, unknown>)?.capabilities_required)).toBe(true);
840
+ }
841
+ });
842
+
843
+ test("session-init is core tier (all 3 harnesses support event.session_start)", () => {
844
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
845
+ fiveHookFixture(),
846
+ minimalCapabilityMatrix(),
847
+ );
848
+
849
+ runBuild(fixtureRoot, wrapperPath);
850
+
851
+ const report = JSON.parse(
852
+ readFileSync(join(fixtureRoot, "dist", "manifests", "portability-report.json"), "utf-8"),
853
+ ) as Record<string, { tier: string; registered_in: string[] }>;
854
+
855
+ expect(report["session-init"]?.tier).toBe("core");
856
+ expect(report["session-init"]?.registered_in).toEqual(
857
+ expect.arrayContaining(["claude", "codex", "opencode"]),
858
+ );
859
+ });
860
+ });
861
+
862
+ // ---------------------------------------------------------------------------
863
+ // Scenario 3: post-tool-telemetry Codex excluded → partial tier
864
+ // ---------------------------------------------------------------------------
865
+
866
+ describe("Scenario 3 — post-tool-telemetry Codex excluded, tier derivation", () => {
867
+ test("post-tool-telemetry is excluded from codex (event.post_tool_use.edit=false)", () => {
868
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
869
+ fiveHookFixture(),
870
+ minimalCapabilityMatrix(),
871
+ );
872
+
873
+ runBuild(fixtureRoot, wrapperPath);
874
+
875
+ const report = JSON.parse(
876
+ readFileSync(join(fixtureRoot, "dist", "manifests", "portability-report.json"), "utf-8"),
877
+ ) as Record<
878
+ string,
879
+ {
880
+ tier: string;
881
+ registered_in: string[];
882
+ excluded_from: Array<{ harness: string; missing: string[] }>;
883
+ capabilities_required: string[];
884
+ }
885
+ >;
886
+
887
+ const telemetry = report["post-tool-telemetry"];
888
+ expect(telemetry).toBeDefined();
889
+
890
+ // Codex must be in excluded_from because event.post_tool_use.edit=false
891
+ const codexExclusion = telemetry!.excluded_from.find((e) => e.harness === "codex");
892
+ expect(codexExclusion).toBeDefined();
893
+ expect(codexExclusion!.missing).toContain("event.post_tool_use.edit");
894
+ });
895
+
896
+ test("post-tool-telemetry tier is 'extended' (claude+opencode only)", () => {
897
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
898
+ fiveHookFixture(),
899
+ minimalCapabilityMatrix(),
900
+ );
901
+
902
+ runBuild(fixtureRoot, wrapperPath);
903
+
904
+ const report = JSON.parse(
905
+ readFileSync(join(fixtureRoot, "dist", "manifests", "portability-report.json"), "utf-8"),
906
+ ) as Record<string, { tier: string; registered_in: string[] }>;
907
+
908
+ // event.post_tool_use.bash_parsed is claude=false, codex=true, opencode=false
909
+ // event.post_tool_use.edit is claude=true, codex=false, opencode=true
910
+ // event.post_tool_use.read is claude=true, codex=false, opencode=true
911
+ // Claude: missing bash_parsed → excluded; Codex: missing edit+read → excluded;
912
+ // OpenCode: missing bash_parsed → excluded
913
+ // If all 3 harnesses are excluded... tier=experimental
914
+ // But let's check: codex has bash_parsed=true but edit=false, read=false → excluded
915
+ // claude has edit=true, read=true, but bash_parsed=false → excluded
916
+ // opencode has edit=true, read=true, but bash_parsed=false → excluded
917
+ // So post-tool-telemetry is registered in nobody → experimental
918
+ const telemetry = report["post-tool-telemetry"];
919
+ // All three harnesses are excluded → 0 registered → experimental tier
920
+ expect(telemetry?.tier).toBe("experimental");
921
+ });
922
+
923
+ test("post-tool-telemetry excluded_from has reason field populated", () => {
924
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
925
+ fiveHookFixture(),
926
+ minimalCapabilityMatrix(),
927
+ );
928
+
929
+ runBuild(fixtureRoot, wrapperPath);
930
+
931
+ const report = JSON.parse(
932
+ readFileSync(join(fixtureRoot, "dist", "manifests", "portability-report.json"), "utf-8"),
933
+ ) as Record<
934
+ string,
935
+ { excluded_from: Array<{ harness: string; missing: string[]; reason: string }> }
936
+ >;
937
+
938
+ const exclusions = report["post-tool-telemetry"]?.excluded_from ?? [];
939
+ for (const exc of exclusions) {
940
+ expect(typeof exc.reason).toBe("string");
941
+ expect(exc.reason.length).toBeGreaterThan(0);
942
+ }
943
+ });
944
+ });
945
+
946
+ // ---------------------------------------------------------------------------
947
+ // Scenario 4: meta.yml에 portability_tier 명시 → 빌드 실패 (zod strict)
948
+ // ---------------------------------------------------------------------------
949
+
950
+ describe("Scenario 4 — portability_tier in meta.yml → zod strict 빌드 실패", () => {
951
+ test("build fails when meta.yml has unknown field portability_tier", () => {
952
+ const invalidHook = {
953
+ name: "bad-hook",
954
+ meta: `name: bad-hook
955
+ description: Hook with unknown field portability_tier
956
+ events: [SessionStart]
957
+ matcher: "*"
958
+ timeout: 10
959
+ fallback: warn
960
+ priority: 0
961
+ portability_tier: core
962
+ requires_capabilities:
963
+ - event.session_start
964
+ `,
965
+ };
966
+
967
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
968
+ [invalidHook],
969
+ minimalCapabilityMatrix(),
970
+ );
971
+
972
+ const { exitCode, stderr } = runBuild(fixtureRoot, wrapperPath);
973
+ expect(exitCode).not.toBe(0);
974
+ expect(stderr.toLowerCase()).toMatch(/portability_tier|unrecognized|meta\.yml/i);
975
+ });
976
+ });
977
+
978
+ // ---------------------------------------------------------------------------
979
+ // Scenario 5: 존재하지 않는 capability ID → 빌드 실패
980
+ // ---------------------------------------------------------------------------
981
+
982
+ describe("Scenario 5 — 존재하지 않는 capability ID → 빌드 실패", () => {
983
+ test("build fails when hook references nonexistent capability", () => {
984
+ const hookWithBadCap = {
985
+ name: "hook-bad-cap",
986
+ meta: `name: hook-bad-cap
987
+ description: Hook that references a capability that does not exist
988
+ events: [SessionStart]
989
+ matcher: "*"
990
+ timeout: 10
991
+ fallback: warn
992
+ priority: 0
993
+ requires_capabilities:
994
+ - does.not.exist.capability.id
995
+ `,
996
+ };
997
+
998
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
999
+ [hookWithBadCap],
1000
+ minimalCapabilityMatrix(),
1001
+ );
1002
+
1003
+ const { exitCode, stderr } = runBuild(fixtureRoot, wrapperPath);
1004
+ expect(exitCode).not.toBe(0);
1005
+ expect(stderr).toContain("does.not.exist.capability.id");
1006
+ });
1007
+ });
1008
+
1009
+ // ---------------------------------------------------------------------------
1010
+ // Scenario 6: fallback=error + 미지원 harness → 빌드 실패
1011
+ // ---------------------------------------------------------------------------
1012
+
1013
+ describe("Scenario 6 — fallback=error + unsupported harness → 빌드 실패", () => {
1014
+ test("build fails when fallback=error and harness is missing a required capability", () => {
1015
+ // event.post_tool_use.edit is codex=false
1016
+ // A hook with fallback=error that requires this capability → codex will fail
1017
+ const strictHook = {
1018
+ name: "strict-hook",
1019
+ meta: `name: strict-hook
1020
+ description: Hook with fallback=error requiring codex-unsupported capability
1021
+ events: [PostToolUse]
1022
+ matcher: "*"
1023
+ timeout: 10
1024
+ fallback: error
1025
+ priority: 0
1026
+ requires_capabilities:
1027
+ - event.post_tool_use.edit
1028
+ `,
1029
+ };
1030
+
1031
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
1032
+ [strictHook],
1033
+ minimalCapabilityMatrix(),
1034
+ );
1035
+
1036
+ const { exitCode, stderr } = runBuild(fixtureRoot, wrapperPath);
1037
+ expect(exitCode).not.toBe(0);
1038
+ expect(stderr).toMatch(/fallback=error|missing capabilities/i);
1039
+ });
1040
+
1041
+ test("build succeeds when fallback=skip and harness is missing a required capability", () => {
1042
+ const skipHook = {
1043
+ name: "skip-hook",
1044
+ meta: `name: skip-hook
1045
+ description: Hook with fallback=skip requiring codex-unsupported capability
1046
+ events: [PostToolUse]
1047
+ matcher: "*"
1048
+ timeout: 10
1049
+ fallback: skip
1050
+ priority: 0
1051
+ requires_capabilities:
1052
+ - event.post_tool_use.edit
1053
+ `,
1054
+ };
1055
+
1056
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
1057
+ [skipHook],
1058
+ minimalCapabilityMatrix(),
1059
+ );
1060
+
1061
+ const { exitCode } = runBuild(fixtureRoot, wrapperPath);
1062
+ expect(exitCode).toBe(0);
1063
+ });
1064
+ });
1065
+
1066
+ // ---------------------------------------------------------------------------
1067
+ // Scenario 7: matcher tool-name alias 변환 확인
1068
+ // ---------------------------------------------------------------------------
1069
+
1070
+ describe("Scenario 7 — matcher tool-name alias 변환", () => {
1071
+ test("Bash→Bash (claude), Bash→shell (codex primary), Bash→bash (opencode)", () => {
1072
+ const bashHook = {
1073
+ name: "bash-hook",
1074
+ meta: `name: bash-hook
1075
+ description: Hook using Bash matcher to test tool name translation
1076
+ events: [PostToolUse]
1077
+ matcher: "Bash"
1078
+ timeout: 5
1079
+ fallback: warn
1080
+ priority: 0
1081
+ requires_capabilities:
1082
+ - event.post_tool_use.bash
1083
+ `,
1084
+ };
1085
+
1086
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
1087
+ [bashHook],
1088
+ minimalCapabilityMatrix(),
1089
+ );
1090
+
1091
+ runBuild(fixtureRoot, wrapperPath);
1092
+
1093
+ const manifestDir = join(fixtureRoot, "dist", "manifests");
1094
+
1095
+ const claude = JSON.parse(readFileSync(join(manifestDir, "claude-hooks.json"), "utf-8")) as {
1096
+ hooks: Record<string, Array<{ matcher: string }>>;
1097
+ };
1098
+ const codex = JSON.parse(readFileSync(join(manifestDir, "codex-hooks.json"), "utf-8")) as {
1099
+ hooks: Record<string, Array<{ matcher?: string }>>;
1100
+ };
1101
+ const opencode = JSON.parse(
1102
+ readFileSync(join(manifestDir, "opencode-hooks.json"), "utf-8"),
1103
+ ) as { mountHooks: Array<{ event: string; matcher?: string }> };
1104
+
1105
+ // Claude: Bash → "Bash"
1106
+ expect(claude.hooks["PostToolUse"]?.[0]?.matcher).toBe("Bash");
1107
+
1108
+ // Codex: Bash → "shell" (primary of codex entry)
1109
+ expect(codex.hooks["PostToolUse"]?.[0]?.matcher).toBe("shell");
1110
+
1111
+ // OpenCode: Bash → "bash"
1112
+ expect(
1113
+ opencode.mountHooks.find((h) => h.event === "PostToolUse")?.matcher,
1114
+ ).toBe("bash");
1115
+ });
1116
+
1117
+ test("wildcard matcher '*' is passed through unchanged for all harnesses", () => {
1118
+ const wildcardHook = {
1119
+ name: "wildcard-hook",
1120
+ meta: `name: wildcard-hook
1121
+ description: Hook with wildcard matcher
1122
+ events: [SessionStart]
1123
+ matcher: "*"
1124
+ timeout: 10
1125
+ fallback: warn
1126
+ priority: 0
1127
+ requires_capabilities:
1128
+ - event.session_start
1129
+ `,
1130
+ };
1131
+
1132
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
1133
+ [wildcardHook],
1134
+ minimalCapabilityMatrix(),
1135
+ );
1136
+
1137
+ runBuild(fixtureRoot, wrapperPath);
1138
+
1139
+ const manifestDir = join(fixtureRoot, "dist", "manifests");
1140
+
1141
+ // Claude manifest has matcher field on every event entry
1142
+ const claude = JSON.parse(readFileSync(join(manifestDir, "claude-hooks.json"), "utf-8")) as {
1143
+ hooks: Record<string, Array<{ matcher: string }>>;
1144
+ };
1145
+ expect(claude.hooks["SessionStart"]?.[0]?.matcher).toBe("*");
1146
+
1147
+ // Codex: wildcard → no matcher field (per build-hooks behavior)
1148
+ const codex = JSON.parse(readFileSync(join(manifestDir, "codex-hooks.json"), "utf-8")) as {
1149
+ hooks: Record<string, Array<{ matcher?: string; command: string }>>;
1150
+ };
1151
+ const codexEntry = codex.hooks["SessionStart"]?.[0];
1152
+ expect(codexEntry).toBeDefined();
1153
+ expect(codexEntry?.matcher).toBeUndefined();
1154
+
1155
+ // OpenCode: wildcard → no matcher field
1156
+ const opencode = JSON.parse(
1157
+ readFileSync(join(manifestDir, "opencode-hooks.json"), "utf-8"),
1158
+ ) as { mountHooks: Array<{ event: string; matcher?: string }> };
1159
+ const ocEntry = opencode.mountHooks.find((h) => h.event === "SessionStart");
1160
+ expect(ocEntry).toBeDefined();
1161
+ expect(ocEntry?.matcher).toBeUndefined();
1162
+ });
1163
+
1164
+ test("pipe-separated matcher translates each token independently", () => {
1165
+ // Use a capability that all 3 harnesses support so the hook is registered in all.
1166
+ // Matcher: Edit|Write
1167
+ // Claude: Edit→Edit, Write→Write → "Edit|Write"
1168
+ // Codex: Edit→apply_patch, Write→apply_patch → deduped "apply_patch"
1169
+ // OpenCode: Edit→edit, Write→write → "edit|write"
1170
+ const multiMatcherHook = {
1171
+ name: "multi-matcher-hook",
1172
+ meta: `name: multi-matcher-hook
1173
+ description: Hook with pipe-separated matcher
1174
+ events: [PostToolUse]
1175
+ matcher: "Edit|Write"
1176
+ timeout: 5
1177
+ fallback: warn
1178
+ priority: 0
1179
+ requires_capabilities:
1180
+ - event.post_tool_use.bash
1181
+ `,
1182
+ };
1183
+
1184
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
1185
+ [multiMatcherHook],
1186
+ minimalCapabilityMatrix(),
1187
+ );
1188
+
1189
+ runBuild(fixtureRoot, wrapperPath);
1190
+
1191
+ const manifestDir = join(fixtureRoot, "dist", "manifests");
1192
+
1193
+ // Claude: Edit|Write → "Edit|Write"
1194
+ const claude = JSON.parse(readFileSync(join(manifestDir, "claude-hooks.json"), "utf-8")) as {
1195
+ hooks: Record<string, Array<{ matcher: string }>>;
1196
+ };
1197
+ expect(claude.hooks["PostToolUse"]?.[0]?.matcher).toBe("Edit|Write");
1198
+
1199
+ // Codex: Edit → apply_patch, Write → apply_patch → deduped to "apply_patch"
1200
+ const codex = JSON.parse(readFileSync(join(manifestDir, "codex-hooks.json"), "utf-8")) as {
1201
+ hooks: Record<string, Array<{ matcher?: string }>>;
1202
+ };
1203
+ expect(codex.hooks["PostToolUse"]?.[0]?.matcher).toBe("apply_patch");
1204
+
1205
+ // OpenCode: Edit → "edit", Write → "write" → "edit|write"
1206
+ const opencode = JSON.parse(
1207
+ readFileSync(join(manifestDir, "opencode-hooks.json"), "utf-8"),
1208
+ ) as { mountHooks: Array<{ event: string; matcher?: string }> };
1209
+ const ocMatcher = opencode.mountHooks.find((h) => h.event === "PostToolUse")?.matcher;
1210
+ expect(ocMatcher).toBe("edit|write");
1211
+ });
1212
+ });
1213
+
1214
+ // ---------------------------------------------------------------------------
1215
+ // Scenario 8: priority 정렬 확인
1216
+ // ---------------------------------------------------------------------------
1217
+
1218
+ describe("Scenario 8 — priority 정렬 (높은 priority 먼저)", () => {
1219
+ test("hooks are emitted in manifest in descending priority order", () => {
1220
+ const hooksWithPriority = [
1221
+ {
1222
+ name: "low-priority-hook",
1223
+ meta: `name: low-priority-hook
1224
+ description: Hook with priority 0
1225
+ events: [SessionStart]
1226
+ matcher: "*"
1227
+ timeout: 10
1228
+ fallback: warn
1229
+ priority: 0
1230
+ requires_capabilities:
1231
+ - event.session_start
1232
+ `,
1233
+ },
1234
+ {
1235
+ name: "high-priority-hook",
1236
+ meta: `name: high-priority-hook
1237
+ description: Hook with priority 100
1238
+ events: [SessionStart]
1239
+ matcher: "*"
1240
+ timeout: 10
1241
+ fallback: warn
1242
+ priority: 100
1243
+ requires_capabilities:
1244
+ - event.session_start
1245
+ `,
1246
+ },
1247
+ {
1248
+ name: "mid-priority-hook",
1249
+ meta: `name: mid-priority-hook
1250
+ description: Hook with priority 50
1251
+ events: [SessionStart]
1252
+ matcher: "*"
1253
+ timeout: 10
1254
+ fallback: warn
1255
+ priority: 50
1256
+ requires_capabilities:
1257
+ - event.session_start
1258
+ `,
1259
+ },
1260
+ ];
1261
+
1262
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
1263
+ hooksWithPriority,
1264
+ minimalCapabilityMatrix(),
1265
+ );
1266
+
1267
+ runBuild(fixtureRoot, wrapperPath);
1268
+
1269
+ const manifestDir = join(fixtureRoot, "dist", "manifests");
1270
+
1271
+ // Claude manifest: hook commands should appear in priority order
1272
+ const claude = JSON.parse(readFileSync(join(manifestDir, "claude-hooks.json"), "utf-8")) as {
1273
+ hooks: Record<
1274
+ string,
1275
+ Array<{ matcher: string; hooks: Array<{ command: string }> }>
1276
+ >;
1277
+ };
1278
+
1279
+ const sessionStartEntries = claude.hooks["SessionStart"];
1280
+ expect(sessionStartEntries).toBeDefined();
1281
+ expect(sessionStartEntries!.length).toBe(3);
1282
+
1283
+ const commands = sessionStartEntries!.map((e) => e.hooks[0]?.command ?? "");
1284
+ // high-priority-hook (100) must appear before mid-priority-hook (50) must appear before low-priority-hook (0)
1285
+ const highIdx = commands.findIndex((c) => c.includes("high-priority-hook"));
1286
+ const midIdx = commands.findIndex((c) => c.includes("mid-priority-hook"));
1287
+ const lowIdx = commands.findIndex((c) => c.includes("low-priority-hook"));
1288
+
1289
+ expect(highIdx).toBeLessThan(midIdx);
1290
+ expect(midIdx).toBeLessThan(lowIdx);
1291
+ });
1292
+
1293
+ test("portability-report preserves priority-based ordering (high before low)", () => {
1294
+ const hooksWithPriority = [
1295
+ {
1296
+ name: "hook-priority-1",
1297
+ meta: `name: hook-priority-1
1298
+ description: Priority 1 hook
1299
+ events: [SessionStart]
1300
+ matcher: "*"
1301
+ timeout: 10
1302
+ fallback: warn
1303
+ priority: 1
1304
+ requires_capabilities:
1305
+ - event.session_start
1306
+ `,
1307
+ },
1308
+ {
1309
+ name: "hook-priority-99",
1310
+ meta: `name: hook-priority-99
1311
+ description: Priority 99 hook
1312
+ events: [SessionStart]
1313
+ matcher: "*"
1314
+ timeout: 10
1315
+ fallback: warn
1316
+ priority: 99
1317
+ requires_capabilities:
1318
+ - event.session_start
1319
+ `,
1320
+ },
1321
+ ];
1322
+
1323
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
1324
+ hooksWithPriority,
1325
+ minimalCapabilityMatrix(),
1326
+ );
1327
+
1328
+ runBuild(fixtureRoot, wrapperPath);
1329
+
1330
+ const claude = JSON.parse(
1331
+ readFileSync(join(fixtureRoot, "dist", "manifests", "claude-hooks.json"), "utf-8"),
1332
+ ) as {
1333
+ hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>>;
1334
+ };
1335
+
1336
+ const entries = claude.hooks["SessionStart"];
1337
+ expect(entries).toBeDefined();
1338
+ expect(entries!.length).toBe(2);
1339
+
1340
+ const first = entries![0]!.hooks[0]!.command;
1341
+ const second = entries![1]!.hooks[0]!.command;
1342
+
1343
+ expect(first).toContain("hook-priority-99");
1344
+ expect(second).toContain("hook-priority-1");
1345
+ });
1346
+ });
1347
+
1348
+ // ---------------------------------------------------------------------------
1349
+ // Additional: real assets/ isolation confirmation
1350
+ // ---------------------------------------------------------------------------
1351
+
1352
+ describe("Isolation — tmp dir 사용, real assets/ 격리 확인", () => {
1353
+ test("fixture root is not the real repo root", () => {
1354
+ const { fixtureRoot } = makeTmpAndWrapper(
1355
+ fiveHookFixture(),
1356
+ minimalCapabilityMatrix(),
1357
+ );
1358
+
1359
+ expect(fixtureRoot).not.toBe(REPO_ROOT);
1360
+ expect(fixtureRoot).toMatch(/build-hooks-test-/);
1361
+ });
1362
+
1363
+ test("build outputs go to tmp fixture dist/, not repo dist/", () => {
1364
+ const { fixtureRoot, wrapperPath } = makeTmpAndWrapper(
1365
+ fiveHookFixture(),
1366
+ minimalCapabilityMatrix(),
1367
+ );
1368
+
1369
+ const repoManifest = join(REPO_ROOT, "dist", "manifests", "claude-hooks.json");
1370
+ const repoMtimeBefore = existsSync(repoManifest)
1371
+ ? statSync(repoManifest).mtimeMs
1372
+ : null;
1373
+
1374
+ runBuild(fixtureRoot, wrapperPath);
1375
+
1376
+ const fixtureManifest = join(fixtureRoot, "dist", "manifests", "claude-hooks.json");
1377
+
1378
+ expect(existsSync(fixtureManifest)).toBe(true);
1379
+ // Isolation check: repo dist/ manifest must NOT be touched by this test run.
1380
+ // Content equality between fixture and repo is allowed (deterministic output).
1381
+ if (repoMtimeBefore !== null) {
1382
+ expect(statSync(repoManifest).mtimeMs).toBe(repoMtimeBefore);
1383
+ }
1384
+ });
1385
+ });