@moreih29/nexus-core 0.13.0 → 0.14.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.
- package/README.md +34 -0
- package/dist/assets/hooks/agent-bootstrap/handler.d.ts +4 -0
- package/dist/assets/hooks/agent-bootstrap/handler.d.ts.map +1 -0
- package/dist/assets/hooks/agent-bootstrap/handler.js +100 -0
- package/dist/assets/hooks/agent-bootstrap/handler.js.map +1 -0
- package/dist/assets/hooks/agent-finalize/handler.d.ts +4 -0
- package/dist/assets/hooks/agent-finalize/handler.d.ts.map +1 -0
- package/dist/assets/hooks/agent-finalize/handler.js +63 -0
- package/dist/assets/hooks/agent-finalize/handler.js.map +1 -0
- package/dist/assets/hooks/post-tool-telemetry/handler.d.ts +4 -0
- package/dist/assets/hooks/post-tool-telemetry/handler.d.ts.map +1 -0
- package/dist/assets/hooks/post-tool-telemetry/handler.js +40 -0
- package/dist/assets/hooks/post-tool-telemetry/handler.js.map +1 -0
- package/dist/assets/hooks/prompt-router/handler.d.ts +4 -0
- package/dist/assets/hooks/prompt-router/handler.d.ts.map +1 -0
- package/dist/assets/hooks/prompt-router/handler.js +204 -0
- package/dist/assets/hooks/prompt-router/handler.js.map +1 -0
- package/dist/assets/hooks/session-init/handler.d.ts +4 -0
- package/dist/assets/hooks/session-init/handler.d.ts.map +1 -0
- package/dist/assets/hooks/session-init/handler.js +23 -0
- package/dist/assets/hooks/session-init/handler.js.map +1 -0
- package/dist/hooks/agent-bootstrap.js +105 -0
- package/dist/hooks/agent-finalize.js +164 -0
- package/dist/hooks/post-tool-telemetry.js +55 -0
- package/dist/hooks/prompt-router.js +7300 -0
- package/dist/hooks/session-init.js +21 -0
- package/dist/manifests/claude-hooks.json +52 -0
- package/dist/manifests/codex-hooks.json +28 -0
- package/dist/manifests/opencode-manifest.json +44 -0
- package/dist/manifests/portability-report.json +87 -0
- package/dist/scripts/build-agents.d.ts +157 -0
- package/dist/scripts/build-agents.d.ts.map +1 -0
- package/dist/scripts/build-agents.js +737 -0
- package/dist/scripts/build-agents.js.map +1 -0
- package/dist/scripts/build-hooks.d.ts +16 -0
- package/dist/scripts/build-hooks.d.ts.map +1 -0
- package/dist/scripts/build-hooks.js +388 -0
- package/dist/scripts/build-hooks.js.map +1 -0
- package/dist/scripts/cli.d.ts +54 -0
- package/dist/scripts/cli.d.ts.map +1 -0
- package/dist/scripts/cli.js +467 -0
- package/dist/scripts/cli.js.map +1 -0
- package/dist/src/hooks/opencode-mount.d.ts.map +1 -0
- package/dist/{hooks → src/hooks}/opencode-mount.js +26 -6
- package/dist/src/hooks/opencode-mount.js.map +1 -0
- package/dist/src/hooks/runtime.d.ts.map +1 -0
- package/dist/src/hooks/runtime.js.map +1 -0
- package/dist/src/hooks/types.d.ts.map +1 -0
- package/dist/src/hooks/types.js.map +1 -0
- package/dist/src/lsp/cache.d.ts.map +1 -0
- package/dist/src/lsp/cache.js.map +1 -0
- package/dist/src/lsp/client.d.ts.map +1 -0
- package/dist/src/lsp/client.js.map +1 -0
- package/dist/src/lsp/detect.d.ts.map +1 -0
- package/dist/src/lsp/detect.js.map +1 -0
- package/dist/src/mcp/server.d.ts.map +1 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/dist/src/mcp/tools/artifact.d.ts.map +1 -0
- package/dist/src/mcp/tools/artifact.js.map +1 -0
- package/dist/src/mcp/tools/history.d.ts.map +1 -0
- package/dist/src/mcp/tools/history.js.map +1 -0
- package/dist/src/mcp/tools/lsp.d.ts.map +1 -0
- package/dist/src/mcp/tools/lsp.js.map +1 -0
- package/dist/src/mcp/tools/plan.d.ts.map +1 -0
- package/dist/src/mcp/tools/plan.js.map +1 -0
- package/dist/src/mcp/tools/task.d.ts.map +1 -0
- package/dist/src/mcp/tools/task.js.map +1 -0
- package/dist/src/shared/invocations.d.ts.map +1 -0
- package/dist/src/shared/invocations.js.map +1 -0
- package/dist/src/shared/json-store.d.ts.map +1 -0
- package/dist/src/shared/json-store.js.map +1 -0
- package/dist/src/shared/mcp-utils.d.ts.map +1 -0
- package/dist/src/shared/mcp-utils.js.map +1 -0
- package/dist/src/shared/paths.d.ts.map +1 -0
- package/dist/src/shared/paths.js.map +1 -0
- package/dist/src/shared/tool-log.d.ts.map +1 -0
- package/dist/src/shared/tool-log.js.map +1 -0
- package/dist/src/types/state.d.ts.map +1 -0
- package/dist/src/types/state.js.map +1 -0
- package/docs/plugin-guide.md +36 -0
- package/package.json +25 -17
- package/dist/hooks/opencode-mount.d.ts.map +0 -1
- package/dist/hooks/opencode-mount.js.map +0 -1
- package/dist/hooks/runtime.d.ts.map +0 -1
- package/dist/hooks/runtime.js.map +0 -1
- package/dist/hooks/types.d.ts.map +0 -1
- package/dist/hooks/types.js.map +0 -1
- package/dist/lsp/cache.d.ts.map +0 -1
- package/dist/lsp/cache.js.map +0 -1
- package/dist/lsp/client.d.ts.map +0 -1
- package/dist/lsp/client.js.map +0 -1
- package/dist/lsp/detect.d.ts.map +0 -1
- package/dist/lsp/detect.js.map +0 -1
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/mcp/server.js.map +0 -1
- package/dist/mcp/tools/artifact.d.ts.map +0 -1
- package/dist/mcp/tools/artifact.js.map +0 -1
- package/dist/mcp/tools/history.d.ts.map +0 -1
- package/dist/mcp/tools/history.js.map +0 -1
- package/dist/mcp/tools/lsp.d.ts.map +0 -1
- package/dist/mcp/tools/lsp.js.map +0 -1
- package/dist/mcp/tools/plan.d.ts.map +0 -1
- package/dist/mcp/tools/plan.js.map +0 -1
- package/dist/mcp/tools/task.d.ts.map +0 -1
- package/dist/mcp/tools/task.js.map +0 -1
- package/dist/shared/invocations.d.ts.map +0 -1
- package/dist/shared/invocations.js.map +0 -1
- package/dist/shared/json-store.d.ts.map +0 -1
- package/dist/shared/json-store.js.map +0 -1
- package/dist/shared/mcp-utils.d.ts.map +0 -1
- package/dist/shared/mcp-utils.js.map +0 -1
- package/dist/shared/paths.d.ts.map +0 -1
- package/dist/shared/paths.js.map +0 -1
- package/dist/shared/tool-log.d.ts.map +0 -1
- package/dist/shared/tool-log.js.map +0 -1
- package/dist/types/state.d.ts.map +0 -1
- package/dist/types/state.js.map +0 -1
- package/scripts/build-agents.test.ts +0 -1279
- package/scripts/build-agents.ts +0 -978
- package/scripts/build-hooks.test.ts +0 -1385
- package/scripts/build-hooks.ts +0 -584
- package/scripts/cli.test.ts +0 -367
- package/scripts/cli.ts +0 -547
- /package/dist/{hooks → src/hooks}/opencode-mount.d.ts +0 -0
- /package/dist/{hooks → src/hooks}/runtime.d.ts +0 -0
- /package/dist/{hooks → src/hooks}/runtime.js +0 -0
- /package/dist/{hooks → src/hooks}/types.d.ts +0 -0
- /package/dist/{hooks → src/hooks}/types.js +0 -0
- /package/dist/{lsp → src/lsp}/cache.d.ts +0 -0
- /package/dist/{lsp → src/lsp}/cache.js +0 -0
- /package/dist/{lsp → src/lsp}/client.d.ts +0 -0
- /package/dist/{lsp → src/lsp}/client.js +0 -0
- /package/dist/{lsp → src/lsp}/detect.d.ts +0 -0
- /package/dist/{lsp → src/lsp}/detect.js +0 -0
- /package/dist/{mcp → src/mcp}/server.d.ts +0 -0
- /package/dist/{mcp → src/mcp}/server.js +0 -0
- /package/dist/{mcp → src/mcp}/tools/artifact.d.ts +0 -0
- /package/dist/{mcp → src/mcp}/tools/artifact.js +0 -0
- /package/dist/{mcp → src/mcp}/tools/history.d.ts +0 -0
- /package/dist/{mcp → src/mcp}/tools/history.js +0 -0
- /package/dist/{mcp → src/mcp}/tools/lsp.d.ts +0 -0
- /package/dist/{mcp → src/mcp}/tools/lsp.js +0 -0
- /package/dist/{mcp → src/mcp}/tools/plan.d.ts +0 -0
- /package/dist/{mcp → src/mcp}/tools/plan.js +0 -0
- /package/dist/{mcp → src/mcp}/tools/task.d.ts +0 -0
- /package/dist/{mcp → src/mcp}/tools/task.js +0 -0
- /package/dist/{shared → src/shared}/invocations.d.ts +0 -0
- /package/dist/{shared → src/shared}/invocations.js +0 -0
- /package/dist/{shared → src/shared}/json-store.d.ts +0 -0
- /package/dist/{shared → src/shared}/json-store.js +0 -0
- /package/dist/{shared → src/shared}/mcp-utils.d.ts +0 -0
- /package/dist/{shared → src/shared}/mcp-utils.js +0 -0
- /package/dist/{shared → src/shared}/paths.d.ts +0 -0
- /package/dist/{shared → src/shared}/paths.js +0 -0
- /package/dist/{shared → src/shared}/tool-log.d.ts +0 -0
- /package/dist/{shared → src/shared}/tool-log.js +0 -0
- /package/dist/{types → src/types}/state.d.ts +0 -0
- /package/dist/{types → src/types}/state.js +0 -0
|
@@ -1,1385 +0,0 @@
|
|
|
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
|
-
});
|