@moreih29/nexus-core 0.12.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 -840
  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 -448
  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 -796
  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 -8
  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,584 @@
1
+ /**
2
+ * scripts/build-hooks.ts
3
+ *
4
+ * Build pipeline for the nexus hook system.
5
+ *
6
+ * Five stages:
7
+ * 1. Load assets/hooks/*\/meta.yml + HookMetaSchema zod validation (strict)
8
+ * 2. Load capability-matrix.yml + validate capability IDs referenced by hooks
9
+ * 3. Warn on event ↔ capability mismatch
10
+ * 4. Compute harness portability with fallback policy enforcement
11
+ * 5. Emit dist/hooks/*.js (tsc), dist/manifests/*.json, portability-report.json
12
+ *
13
+ * 결정 참조: plan.json Issue #5 (빌드 검증 5단계)
14
+ */
15
+
16
+ import { HookMetaSchema } from "../src/hooks/types.js";
17
+ import type { HookMeta } from "../src/hooks/types.js";
18
+ import {
19
+ readFileSync,
20
+ readdirSync,
21
+ existsSync,
22
+ mkdirSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import { join, resolve, dirname } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { execSync } from "node:child_process";
28
+ import { parse as parseYaml } from "yaml";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Constants
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const __dirname = dirname(fileURLToPath(import.meta.url));
35
+ const ROOT = resolve(__dirname, "..");
36
+ const HOOKS_DIR = join(ROOT, "assets/hooks");
37
+ const CAPABILITY_MATRIX_PATH = join(HOOKS_DIR, "capability-matrix.yml");
38
+ const TOOL_NAME_MAP_PATH = join(ROOT, "assets/tools/tool-name-map.yml");
39
+ const DIST_HOOKS_DIR = join(ROOT, "dist/hooks");
40
+ const DIST_MANIFESTS_DIR = join(ROOT, "dist/manifests");
41
+
42
+ const HARNESSES = ["claude", "codex", "opencode"] as const;
43
+ type Harness = (typeof HARNESSES)[number];
44
+ type CapabilityValue = boolean | "partial";
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Data shapes
48
+ // ---------------------------------------------------------------------------
49
+
50
+ interface HookEntry {
51
+ name: string;
52
+ meta: HookMeta;
53
+ handlerPath: string;
54
+ }
55
+
56
+ interface CapabilityMatrix {
57
+ capabilities: Record<
58
+ string,
59
+ { claude: CapabilityValue; codex: CapabilityValue; opencode: CapabilityValue; note?: string }
60
+ >;
61
+ }
62
+
63
+ interface ExclusionRecord {
64
+ harness: Harness;
65
+ missing: string[];
66
+ reason: string;
67
+ }
68
+
69
+ interface PortabilityPlan {
70
+ name: string;
71
+ meta: HookMeta;
72
+ tier: "core" | "extended" | "experimental" | "harness-specific";
73
+ registeredIn: Harness[];
74
+ excludedFrom: ExclusionRecord[];
75
+ capabilitiesRequired: string[];
76
+ }
77
+
78
+ interface ToolNameMap {
79
+ tools: Record<
80
+ string,
81
+ {
82
+ claude: string | string[] | null;
83
+ codex: string | string[] | null | { primary: string; aliases: string[] };
84
+ opencode: string | string[] | null;
85
+ }
86
+ >;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Stage 1: Load + validate meta.yml files
91
+ // ---------------------------------------------------------------------------
92
+
93
+ function loadAllHooks(): HookEntry[] {
94
+ const entries = readdirSync(HOOKS_DIR, { withFileTypes: true });
95
+ const result: HookEntry[] = [];
96
+
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory()) continue;
99
+
100
+ const metaPath = join(HOOKS_DIR, entry.name, "meta.yml");
101
+ const handlerPath = join(HOOKS_DIR, entry.name, "handler.ts");
102
+
103
+ if (!existsSync(metaPath) || !existsSync(handlerPath)) continue;
104
+
105
+ const metaRaw = parseYaml(readFileSync(metaPath, "utf-8"));
106
+
107
+ // HookMetaSchema is .strict() — portability_tier and other unknown fields
108
+ // will throw a ZodError here (Acceptance Criteria #5)
109
+ let meta: HookMeta;
110
+ try {
111
+ meta = HookMetaSchema.parse(metaRaw);
112
+ } catch (err) {
113
+ throw new Error(
114
+ `[build-hooks] meta.yml validation failed for "${entry.name}": ${String(err)}`,
115
+ );
116
+ }
117
+
118
+ result.push({ name: entry.name, meta, handlerPath });
119
+ }
120
+
121
+ // Sort by priority descending
122
+ result.sort((a, b) => b.meta.priority - a.meta.priority);
123
+
124
+ return result;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Stage 2: Load capability matrix + validate capability IDs
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function loadCapabilityMatrix(): CapabilityMatrix {
132
+ const raw = readFileSync(CAPABILITY_MATRIX_PATH, "utf-8");
133
+ return parseYaml(raw) as CapabilityMatrix;
134
+ }
135
+
136
+ function validateCapabilityIds(hooks: HookEntry[], matrix: CapabilityMatrix): void {
137
+ const knownIds = new Set(Object.keys(matrix.capabilities));
138
+
139
+ for (const hook of hooks) {
140
+ for (const capId of hook.meta.requires_capabilities) {
141
+ if (!knownIds.has(capId)) {
142
+ // Acceptance Criteria #6: unknown capability ID → build failure
143
+ throw new Error(
144
+ `[build-hooks] "${hook.name}" requires unknown capability "${capId}". ` +
145
+ `Known IDs: ${[...knownIds].join(", ")}`,
146
+ );
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Stage 3: Event ↔ capability mismatch warnings
154
+ // ---------------------------------------------------------------------------
155
+
156
+ type HookEventName =
157
+ | "SessionStart"
158
+ | "UserPromptSubmit"
159
+ | "PreToolUse"
160
+ | "PostToolUse"
161
+ | "SubagentStart"
162
+ | "SubagentStop";
163
+
164
+ /** Capability prefix → events it belongs to */
165
+ const CAP_EVENT_MAP: Array<{ prefix: string; events: HookEventName[] }> = [
166
+ { prefix: "event.session_start", events: ["SessionStart" as const] },
167
+ { prefix: "event.user_prompt_submit", events: ["UserPromptSubmit" as const] },
168
+ { prefix: "event.pre_tool_use", events: ["PreToolUse" as const] },
169
+ { prefix: "event.post_tool_use", events: ["PostToolUse" as const] },
170
+ { prefix: "event.subagent_start", events: ["SubagentStart" as const] },
171
+ { prefix: "event.subagent_stop", events: ["SubagentStop" as const] },
172
+ {
173
+ prefix: "output.additional_context.session_start",
174
+ events: ["SessionStart" as const, "SubagentStart" as const],
175
+ },
176
+ { prefix: "output.additional_context.user_prompt", events: ["UserPromptSubmit" as const] },
177
+ { prefix: "output.additional_context.subagent_stop", events: ["SubagentStop" as const] },
178
+ { prefix: "output.additional_context.post_tool", events: ["PostToolUse" as const] },
179
+ { prefix: "output.additional_context.pre_tool", events: ["PreToolUse" as const] },
180
+ ];
181
+
182
+ function warnOnMismatch(hooks: HookEntry[]): void {
183
+ for (const hook of hooks) {
184
+ const hookEvents = new Set(hook.meta.events);
185
+
186
+ for (const capId of hook.meta.requires_capabilities) {
187
+ for (const mapping of CAP_EVENT_MAP) {
188
+ if (!capId.startsWith(mapping.prefix)) continue;
189
+
190
+ const capEvents = new Set(mapping.events);
191
+ const overlap = mapping.events.some((e) => hookEvents.has(e));
192
+
193
+ if (!overlap) {
194
+ process.stderr.write(
195
+ `[build-hooks] WARN mismatch: hook "${hook.name}" listens on [${hook.meta.events.join(", ")}] ` +
196
+ `but capability "${capId}" applies to [${mapping.events.join(", ")}]\n`,
197
+ );
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Stage 4: Harness portability computation + fallback policy
206
+ // ---------------------------------------------------------------------------
207
+
208
+ function computePortability(hooks: HookEntry[], matrix: CapabilityMatrix): PortabilityPlan[] {
209
+ const plans: PortabilityPlan[] = [];
210
+
211
+ for (const hook of hooks) {
212
+ const registeredIn: Harness[] = [];
213
+ const excludedFrom: ExclusionRecord[] = [];
214
+
215
+ for (const harness of HARNESSES) {
216
+ const missingCaps: string[] = [];
217
+
218
+ for (const capId of hook.meta.requires_capabilities) {
219
+ const capEntry = matrix.capabilities[capId];
220
+ if (!capEntry) continue; // already caught in validateCapabilityIds
221
+
222
+ const support = capEntry[harness];
223
+ // Only `true` counts as fully supported; false and partial are unsupported
224
+ if (support !== true) {
225
+ missingCaps.push(capId);
226
+ }
227
+ }
228
+
229
+ if (missingCaps.length === 0) {
230
+ registeredIn.push(harness);
231
+ } else {
232
+ // Determine reason string from capability notes
233
+ const reasons = missingCaps
234
+ .map((capId) => {
235
+ const entry = matrix.capabilities[capId];
236
+ if (!entry?.note) return capId;
237
+ // Trim the note to a concise first sentence
238
+ const note = entry.note.trim().split(/\.\s/)[0]?.trim() ?? capId;
239
+ return note;
240
+ })
241
+ .join("; ");
242
+
243
+ // Apply fallback policy (Acceptance Criteria #7)
244
+ if (hook.meta.fallback === "error") {
245
+ throw new Error(
246
+ `[build-hooks] Hook "${hook.name}" fallback=error but harness "${harness}" ` +
247
+ `is missing capabilities: ${missingCaps.join(", ")}`,
248
+ );
249
+ }
250
+
251
+ if (hook.meta.fallback === "warn") {
252
+ process.stderr.write(
253
+ `[build-hooks] WARN: hook "${hook.name}" excluded from "${harness}" ` +
254
+ `(missing: ${missingCaps.join(", ")})\n`,
255
+ );
256
+ }
257
+ // fallback=skip: no warning, silently excluded
258
+
259
+ excludedFrom.push({ harness, missing: missingCaps, reason: reasons });
260
+ }
261
+ }
262
+
263
+ const tier = deriveTier(registeredIn, hook.meta.fallback);
264
+
265
+ plans.push({
266
+ name: hook.name,
267
+ meta: hook.meta,
268
+ tier,
269
+ registeredIn,
270
+ excludedFrom,
271
+ capabilitiesRequired: hook.meta.requires_capabilities,
272
+ });
273
+ }
274
+
275
+ return plans;
276
+ }
277
+
278
+ function deriveTier(
279
+ registeredIn: Harness[],
280
+ fallback: HookMeta["fallback"],
281
+ ): PortabilityPlan["tier"] {
282
+ const count = registeredIn.length;
283
+ if (count === 3) return "core";
284
+ if (count === 2) return "extended";
285
+ if (count === 1) {
286
+ // harness-specific: intentional skip-only registration in one harness
287
+ if (fallback === "skip") return "harness-specific";
288
+ return "experimental";
289
+ }
290
+ // count === 0: excluded everywhere — still experimental (build already failed if fallback=error)
291
+ return "experimental";
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Stage 5a: Compile handlers via tsc
296
+ //
297
+ // Each handler.ts imports from src/ (types, shared utilities), so rootDir
298
+ // cannot be scoped to assets/hooks alone. We compile the full project tree
299
+ // (rootDir=.) into a temporary directory, then copy only the handler outputs
300
+ // to dist/hooks/<name>.js — preserving the flat dist/hooks/<name>.js layout
301
+ // required by the manifests.
302
+ // ---------------------------------------------------------------------------
303
+
304
+ function compileHandlers(hooks: HookEntry[]): void {
305
+ mkdirSync(DIST_HOOKS_DIR, { recursive: true });
306
+
307
+ // Compile each handler as a standalone bundle using bun build.
308
+ // This resolves cross-directory imports (src/hooks/types.js etc.) cleanly
309
+ // and produces a single-file output per hook at dist/hooks/<name>.js.
310
+ for (const hook of hooks) {
311
+ const outFile = join(DIST_HOOKS_DIR, `${hook.name}.js`);
312
+ try {
313
+ execSync(
314
+ `bun build ${hook.handlerPath} --outfile ${outFile} --target node --format esm`,
315
+ { cwd: ROOT, stdio: "inherit" },
316
+ );
317
+ } catch {
318
+ throw new Error(
319
+ `[build-hooks] Handler compilation failed for "${hook.name}" (bun build exit non-zero)`,
320
+ );
321
+ }
322
+ }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Stage 5b: Write harness manifests
327
+ // ---------------------------------------------------------------------------
328
+
329
+ function loadToolNameMap(): ToolNameMap {
330
+ const raw = readFileSync(TOOL_NAME_MAP_PATH, "utf-8");
331
+ return parseYaml(raw) as ToolNameMap;
332
+ }
333
+
334
+ /**
335
+ * Translate a nexus PascalCase matcher token to the harness-native tool name(s).
336
+ * Returns the original token if no mapping is found.
337
+ *
338
+ * Acceptance Criteria #8: Bash → shell/bash per harness.
339
+ */
340
+ function translateMatcherToken(token: string, harness: Harness, toolMap: ToolNameMap): string {
341
+ const entry = toolMap.tools[token];
342
+ if (!entry) return token;
343
+
344
+ const harnessValue = entry[harness];
345
+ if (harnessValue === null || harnessValue === undefined) return token;
346
+
347
+ if (typeof harnessValue === "string") return harnessValue;
348
+
349
+ if (Array.isArray(harnessValue)) {
350
+ return harnessValue.join("|");
351
+ }
352
+
353
+ // Codex primary/aliases shape
354
+ if (typeof harnessValue === "object" && "primary" in harnessValue) {
355
+ return (harnessValue as { primary: string }).primary;
356
+ }
357
+
358
+ return token;
359
+ }
360
+
361
+ /**
362
+ * Translate a pipe-separated matcher string to harness-native names.
363
+ * e.g. "Edit|Write|MultiEdit|ApplyPatch" → "Edit|Write|MultiEdit" (claude)
364
+ * → "apply_patch" (codex, deduped)
365
+ * → "edit|write|apply_patch" (opencode, deduped)
366
+ */
367
+ function translateMatcher(matcher: string, harness: Harness, toolMap: ToolNameMap): string {
368
+ if (matcher === "*") return "*";
369
+
370
+ const tokens = matcher.split("|").map((t) => t.trim());
371
+ const translated = new Set<string>();
372
+
373
+ for (const token of tokens) {
374
+ const native = translateMatcherToken(token, harness, toolMap);
375
+ // native may itself be pipe-separated (Array case collapsed above)
376
+ for (const part of native.split("|")) {
377
+ if (part.trim()) translated.add(part.trim());
378
+ }
379
+ }
380
+
381
+ return [...translated].join("|");
382
+ }
383
+
384
+ function hookCommand(hookName: string, harness: Harness): string {
385
+ if (harness === "opencode") {
386
+ // OpenCode uses mountHooks JS API — command field contains the module reference
387
+ return `${hookName}`;
388
+ }
389
+ return `node \${CLAUDE_PLUGIN_ROOT}/dist/hooks/${hookName}.js`;
390
+ }
391
+
392
+ type ClaudeHooksJson = {
393
+ hooks: Record<
394
+ string,
395
+ Array<{ matcher: string; hooks: Array<{ type: string; command: string; timeout: number }> }>
396
+ >;
397
+ };
398
+
399
+ function buildClaudeManifest(plans: PortabilityPlan[], toolMap: ToolNameMap): ClaudeHooksJson {
400
+ const hooks: ClaudeHooksJson["hooks"] = {};
401
+
402
+ for (const plan of plans) {
403
+ if (!plan.registeredIn.includes("claude")) continue;
404
+
405
+ for (const event of plan.meta.events) {
406
+ if (!hooks[event]) hooks[event] = [];
407
+
408
+ const matcher = translateMatcher(plan.meta.matcher, "claude", toolMap);
409
+ hooks[event].push({
410
+ matcher,
411
+ hooks: [
412
+ {
413
+ type: "command",
414
+ command: hookCommand(plan.name, "claude"),
415
+ timeout: plan.meta.timeout,
416
+ },
417
+ ],
418
+ });
419
+ }
420
+ }
421
+
422
+ // Sort events' hook arrays by priority descending (already sorted at plan level)
423
+ return { hooks };
424
+ }
425
+
426
+ type CodexHooksJson = {
427
+ hooks: Record<
428
+ string,
429
+ Array<{ matcher?: string; command: string; timeout: number }>
430
+ >;
431
+ };
432
+
433
+ function buildCodexManifest(plans: PortabilityPlan[], toolMap: ToolNameMap): CodexHooksJson {
434
+ const hooks: CodexHooksJson["hooks"] = {};
435
+
436
+ for (const plan of plans) {
437
+ if (!plan.registeredIn.includes("codex")) continue;
438
+
439
+ for (const event of plan.meta.events) {
440
+ if (!hooks[event]) hooks[event] = [];
441
+
442
+ const matcher = translateMatcher(plan.meta.matcher, "codex", toolMap);
443
+ const entry: { matcher?: string; command: string; timeout: number } = {
444
+ command: hookCommand(plan.name, "codex"),
445
+ timeout: plan.meta.timeout,
446
+ };
447
+ if (matcher !== "*") entry.matcher = matcher;
448
+ hooks[event].push(entry);
449
+ }
450
+ }
451
+
452
+ return { hooks };
453
+ }
454
+
455
+ type OpenCodeHooksJson = {
456
+ mountHooks: Array<{
457
+ event: string;
458
+ matcher?: string;
459
+ module: string;
460
+ timeout: number;
461
+ }>;
462
+ };
463
+
464
+ function buildOpenCodeManifest(plans: PortabilityPlan[], toolMap: ToolNameMap): OpenCodeHooksJson {
465
+ const mountHooks: OpenCodeHooksJson["mountHooks"] = [];
466
+
467
+ for (const plan of plans) {
468
+ if (!plan.registeredIn.includes("opencode")) continue;
469
+
470
+ for (const event of plan.meta.events) {
471
+ const matcher = translateMatcher(plan.meta.matcher, "opencode", toolMap);
472
+ const entry: OpenCodeHooksJson["mountHooks"][number] = {
473
+ event,
474
+ module: `./dist/hooks/${plan.name}.js`,
475
+ timeout: plan.meta.timeout,
476
+ };
477
+ if (matcher !== "*") entry.matcher = matcher;
478
+ mountHooks.push(entry);
479
+ }
480
+ }
481
+
482
+ return { mountHooks };
483
+ }
484
+
485
+ function writeManifests(plans: PortabilityPlan[]): void {
486
+ mkdirSync(DIST_MANIFESTS_DIR, { recursive: true });
487
+
488
+ const toolMap = loadToolNameMap();
489
+
490
+ const claude = buildClaudeManifest(plans, toolMap);
491
+ const codex = buildCodexManifest(plans, toolMap);
492
+ const opencode = buildOpenCodeManifest(plans, toolMap);
493
+
494
+ writeFileSync(
495
+ join(DIST_MANIFESTS_DIR, "claude-hooks.json"),
496
+ JSON.stringify(claude, null, 2) + "\n",
497
+ );
498
+ writeFileSync(
499
+ join(DIST_MANIFESTS_DIR, "codex-hooks.json"),
500
+ JSON.stringify(codex, null, 2) + "\n",
501
+ );
502
+ writeFileSync(
503
+ join(DIST_MANIFESTS_DIR, "opencode-hooks.json"),
504
+ JSON.stringify(opencode, null, 2) + "\n",
505
+ );
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // Stage 5c: Write portability report
510
+ // ---------------------------------------------------------------------------
511
+
512
+ type PortabilityReport = Record<
513
+ string,
514
+ {
515
+ tier: PortabilityPlan["tier"];
516
+ registered_in: Harness[];
517
+ excluded_from: Array<{ harness: Harness; missing: string[]; reason: string }>;
518
+ capabilities_required: string[];
519
+ }
520
+ >;
521
+
522
+ function writePortabilityReport(plans: PortabilityPlan[]): void {
523
+ mkdirSync(DIST_MANIFESTS_DIR, { recursive: true });
524
+
525
+ const report: PortabilityReport = {};
526
+
527
+ for (const plan of plans) {
528
+ report[plan.name] = {
529
+ tier: plan.tier,
530
+ registered_in: plan.registeredIn,
531
+ excluded_from: plan.excludedFrom,
532
+ capabilities_required: plan.capabilitiesRequired,
533
+ };
534
+ }
535
+
536
+ writeFileSync(
537
+ join(DIST_MANIFESTS_DIR, "portability-report.json"),
538
+ JSON.stringify(report, null, 2) + "\n",
539
+ );
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Main entry point
544
+ // ---------------------------------------------------------------------------
545
+
546
+ export async function buildHooks(): Promise<void> {
547
+ // Stage 1
548
+ const hooks = loadAllHooks();
549
+ console.log(`[build-hooks] Loaded ${hooks.length} hooks`);
550
+
551
+ // Stage 2
552
+ const matrix = loadCapabilityMatrix();
553
+ validateCapabilityIds(hooks, matrix);
554
+
555
+ // Stage 3
556
+ warnOnMismatch(hooks);
557
+
558
+ // Stage 4
559
+ const plans = computePortability(hooks, matrix);
560
+
561
+ // Stage 5
562
+ compileHandlers(hooks);
563
+ writeManifests(plans);
564
+ writePortabilityReport(plans);
565
+
566
+ console.log(`[build-hooks] ${hooks.length} hooks processed`);
567
+ for (const plan of plans) {
568
+ console.log(
569
+ ` ${plan.name}: tier=${plan.tier} registered=[${plan.registeredIn.join(",")}]`,
570
+ );
571
+ }
572
+ }
573
+
574
+ // Run when executed directly (bun run scripts/build-hooks.ts)
575
+ if (
576
+ import.meta.url === `file://${process.argv[1]}` ||
577
+ process.argv[1]?.endsWith("build-hooks.ts") ||
578
+ process.argv[1]?.endsWith("build-hooks.js")
579
+ ) {
580
+ buildHooks().catch((err: unknown) => {
581
+ process.stderr.write(`[build-hooks] FATAL: ${String(err)}\n`);
582
+ process.exit(1);
583
+ });
584
+ }