@moreih29/nexus-core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +48 -63
  2. package/assets/agents/architect/body.ko.md +177 -0
  3. package/{agents → assets/agents}/architect/body.md +16 -0
  4. package/assets/agents/designer/body.ko.md +125 -0
  5. package/{agents → assets/agents}/designer/body.md +16 -0
  6. package/assets/agents/engineer/body.ko.md +106 -0
  7. package/{agents → assets/agents}/engineer/body.md +14 -0
  8. package/assets/agents/lead/body.ko.md +70 -0
  9. package/assets/agents/lead/body.md +70 -0
  10. package/assets/agents/postdoc/body.ko.md +122 -0
  11. package/{agents → assets/agents}/postdoc/body.md +16 -0
  12. package/assets/agents/researcher/body.ko.md +137 -0
  13. package/{agents → assets/agents}/researcher/body.md +15 -0
  14. package/assets/agents/reviewer/body.ko.md +138 -0
  15. package/{agents → assets/agents}/reviewer/body.md +15 -0
  16. package/assets/agents/strategist/body.ko.md +116 -0
  17. package/{agents → assets/agents}/strategist/body.md +16 -0
  18. package/assets/agents/tester/body.ko.md +195 -0
  19. package/{agents → assets/agents}/tester/body.md +15 -0
  20. package/assets/agents/writer/body.ko.md +122 -0
  21. package/{agents → assets/agents}/writer/body.md +14 -0
  22. package/assets/capability-matrix.yml +198 -0
  23. package/assets/hooks/agent-bootstrap/handler.test.ts +368 -0
  24. package/assets/hooks/agent-bootstrap/handler.ts +119 -0
  25. package/assets/hooks/agent-bootstrap/meta.yml +10 -0
  26. package/assets/hooks/agent-finalize/handler.test.ts +368 -0
  27. package/assets/hooks/agent-finalize/handler.ts +76 -0
  28. package/assets/hooks/agent-finalize/meta.yml +10 -0
  29. package/assets/hooks/capability-matrix.yml +313 -0
  30. package/assets/hooks/post-tool-telemetry/handler.test.ts +302 -0
  31. package/assets/hooks/post-tool-telemetry/handler.ts +49 -0
  32. package/assets/hooks/post-tool-telemetry/meta.yml +11 -0
  33. package/assets/hooks/prompt-router/handler.test.ts +801 -0
  34. package/assets/hooks/prompt-router/handler.ts +261 -0
  35. package/assets/hooks/prompt-router/meta.yml +11 -0
  36. package/assets/hooks/session-init/handler.test.ts +274 -0
  37. package/assets/hooks/session-init/handler.ts +30 -0
  38. package/assets/hooks/session-init/meta.yml +9 -0
  39. package/assets/lsp-servers.json +55 -0
  40. package/assets/schema/lsp-servers.schema.json +67 -0
  41. package/assets/skills/nx-init/body.ko.md +197 -0
  42. package/{skills → assets/skills}/nx-init/body.md +11 -0
  43. package/assets/skills/nx-plan/body.ko.md +361 -0
  44. package/{skills → assets/skills}/nx-plan/body.md +13 -0
  45. package/assets/skills/nx-run/body.ko.md +161 -0
  46. package/{skills → assets/skills}/nx-run/body.md +11 -0
  47. package/assets/skills/nx-sync/body.ko.md +92 -0
  48. package/{skills → assets/skills}/nx-sync/body.md +10 -0
  49. package/assets/tools/tool-name-map.yml +353 -0
  50. package/dist/hooks/opencode-mount.d.ts +35 -0
  51. package/dist/hooks/opencode-mount.d.ts.map +1 -0
  52. package/dist/hooks/opencode-mount.js +332 -0
  53. package/dist/hooks/opencode-mount.js.map +1 -0
  54. package/dist/hooks/runtime.d.ts +37 -0
  55. package/dist/hooks/runtime.d.ts.map +1 -0
  56. package/dist/hooks/runtime.js +274 -0
  57. package/dist/hooks/runtime.js.map +1 -0
  58. package/dist/hooks/types.d.ts +196 -0
  59. package/dist/hooks/types.d.ts.map +1 -0
  60. package/dist/hooks/types.js +85 -0
  61. package/dist/hooks/types.js.map +1 -0
  62. package/dist/lsp/cache.d.ts +9 -0
  63. package/dist/lsp/cache.d.ts.map +1 -0
  64. package/dist/lsp/cache.js +216 -0
  65. package/dist/lsp/cache.js.map +1 -0
  66. package/dist/lsp/client.d.ts +24 -0
  67. package/dist/lsp/client.d.ts.map +1 -0
  68. package/dist/lsp/client.js +166 -0
  69. package/dist/lsp/client.js.map +1 -0
  70. package/dist/lsp/detect.d.ts +77 -0
  71. package/dist/lsp/detect.d.ts.map +1 -0
  72. package/dist/lsp/detect.js +116 -0
  73. package/dist/lsp/detect.js.map +1 -0
  74. package/dist/mcp/server.d.ts +5 -0
  75. package/dist/mcp/server.d.ts.map +1 -0
  76. package/dist/mcp/server.js +34 -0
  77. package/dist/mcp/server.js.map +1 -0
  78. package/dist/mcp/tools/artifact.d.ts +4 -0
  79. package/dist/mcp/tools/artifact.d.ts.map +1 -0
  80. package/dist/mcp/tools/artifact.js +36 -0
  81. package/dist/mcp/tools/artifact.js.map +1 -0
  82. package/dist/mcp/tools/history.d.ts +3 -0
  83. package/dist/mcp/tools/history.d.ts.map +1 -0
  84. package/dist/mcp/tools/history.js +29 -0
  85. package/dist/mcp/tools/history.js.map +1 -0
  86. package/dist/mcp/tools/lsp.d.ts +13 -0
  87. package/dist/mcp/tools/lsp.d.ts.map +1 -0
  88. package/dist/mcp/tools/lsp.js +225 -0
  89. package/dist/mcp/tools/lsp.js.map +1 -0
  90. package/dist/mcp/tools/plan.d.ts +3 -0
  91. package/dist/mcp/tools/plan.d.ts.map +1 -0
  92. package/dist/mcp/tools/plan.js +317 -0
  93. package/dist/mcp/tools/plan.js.map +1 -0
  94. package/dist/mcp/tools/task.d.ts +3 -0
  95. package/dist/mcp/tools/task.d.ts.map +1 -0
  96. package/dist/mcp/tools/task.js +252 -0
  97. package/dist/mcp/tools/task.js.map +1 -0
  98. package/dist/shared/invocations.d.ts +74 -0
  99. package/dist/shared/invocations.d.ts.map +1 -0
  100. package/dist/shared/invocations.js +247 -0
  101. package/dist/shared/invocations.js.map +1 -0
  102. package/dist/shared/json-store.d.ts +37 -0
  103. package/dist/shared/json-store.d.ts.map +1 -0
  104. package/dist/shared/json-store.js +163 -0
  105. package/dist/shared/json-store.js.map +1 -0
  106. package/dist/shared/mcp-utils.d.ts +3 -0
  107. package/dist/shared/mcp-utils.d.ts.map +1 -0
  108. package/dist/shared/mcp-utils.js +6 -0
  109. package/dist/shared/mcp-utils.js.map +1 -0
  110. package/dist/shared/paths.d.ts +21 -0
  111. package/dist/shared/paths.d.ts.map +1 -0
  112. package/dist/shared/paths.js +81 -0
  113. package/dist/shared/paths.js.map +1 -0
  114. package/dist/shared/tool-log.d.ts +8 -0
  115. package/dist/shared/tool-log.d.ts.map +1 -0
  116. package/dist/shared/tool-log.js +22 -0
  117. package/dist/shared/tool-log.js.map +1 -0
  118. package/dist/types/state.d.ts +862 -0
  119. package/dist/types/state.d.ts.map +1 -0
  120. package/dist/types/state.js +66 -0
  121. package/dist/types/state.js.map +1 -0
  122. package/docs/consuming/codex-lead-merge.md +106 -0
  123. package/docs/plugin-guide.md +360 -0
  124. package/docs/plugin-template/claude/.github/workflows/build.yml +60 -0
  125. package/docs/plugin-template/claude/README.md +110 -0
  126. package/docs/plugin-template/claude/package.json +16 -0
  127. package/docs/plugin-template/codex/.github/workflows/build.yml +51 -0
  128. package/docs/plugin-template/codex/README.md +147 -0
  129. package/docs/plugin-template/codex/package.json +17 -0
  130. package/docs/plugin-template/opencode/.github/workflows/build.yml +61 -0
  131. package/docs/plugin-template/opencode/README.md +121 -0
  132. package/docs/plugin-template/opencode/package.json +25 -0
  133. package/package.json +21 -21
  134. package/scripts/build-agents.test.ts +1279 -0
  135. package/scripts/build-agents.ts +978 -0
  136. package/scripts/build-hooks.test.ts +1385 -0
  137. package/scripts/build-hooks.ts +584 -0
  138. package/scripts/cli.test.ts +367 -0
  139. package/scripts/cli.ts +547 -0
  140. package/agents/architect/meta.yml +0 -13
  141. package/agents/designer/meta.yml +0 -13
  142. package/agents/engineer/meta.yml +0 -11
  143. package/agents/postdoc/meta.yml +0 -13
  144. package/agents/researcher/meta.yml +0 -12
  145. package/agents/reviewer/meta.yml +0 -12
  146. package/agents/strategist/meta.yml +0 -13
  147. package/agents/tester/meta.yml +0 -12
  148. package/agents/writer/meta.yml +0 -11
  149. package/conformance/README.md +0 -311
  150. package/conformance/examples/plan.extension.schema.example.json +0 -25
  151. package/conformance/lifecycle/README.md +0 -48
  152. package/conformance/lifecycle/agent-complete.json +0 -44
  153. package/conformance/lifecycle/agent-resume.json +0 -43
  154. package/conformance/lifecycle/agent-spawn.json +0 -36
  155. package/conformance/lifecycle/memory-access-record.json +0 -27
  156. package/conformance/lifecycle/session-end.json +0 -48
  157. package/conformance/scenarios/full-plan-cycle.json +0 -147
  158. package/conformance/scenarios/task-deps-ordering.json +0 -95
  159. package/conformance/schema/fixture.schema.json +0 -354
  160. package/conformance/state-schemas/agent-tracker.schema.json +0 -63
  161. package/conformance/state-schemas/history.schema.json +0 -134
  162. package/conformance/state-schemas/memory-access.schema.json +0 -36
  163. package/conformance/state-schemas/plan.schema.json +0 -77
  164. package/conformance/state-schemas/tasks.schema.json +0 -98
  165. package/conformance/tools/artifact-write.json +0 -97
  166. package/conformance/tools/context.json +0 -172
  167. package/conformance/tools/history-search.json +0 -219
  168. package/conformance/tools/plan-decide.json +0 -139
  169. package/conformance/tools/plan-start.json +0 -81
  170. package/conformance/tools/plan-status.json +0 -127
  171. package/conformance/tools/plan-update.json +0 -341
  172. package/conformance/tools/task-add.json +0 -156
  173. package/conformance/tools/task-close.json +0 -161
  174. package/conformance/tools/task-list.json +0 -177
  175. package/conformance/tools/task-update.json +0 -167
  176. package/docs/behavioral-contracts.md +0 -145
  177. package/docs/consumer-implementation-guide.md +0 -852
  178. package/docs/memory-lifecycle-contract.md +0 -119
  179. package/docs/nexus-layout.md +0 -224
  180. package/docs/nexus-outputs-contract.md +0 -344
  181. package/docs/nexus-state-overview.md +0 -170
  182. package/docs/nexus-tools-contract.md +0 -438
  183. package/manifest.json +0 -449
  184. package/schema/README.md +0 -69
  185. package/schema/agent.schema.json +0 -23
  186. package/schema/common.schema.json +0 -17
  187. package/schema/manifest.schema.json +0 -78
  188. package/schema/memory-policy.schema.json +0 -98
  189. package/schema/skill.schema.json +0 -54
  190. package/schema/task-exceptions.schema.json +0 -40
  191. package/schema/vocabulary.schema.json +0 -167
  192. package/scripts/.gitkeep +0 -0
  193. package/scripts/conformance-coverage.ts +0 -466
  194. package/scripts/import-from-claude-nexus.ts +0 -403
  195. package/scripts/lib/frontmatter.ts +0 -71
  196. package/scripts/lib/lint.ts +0 -348
  197. package/scripts/lib/structure.ts +0 -159
  198. package/scripts/lib/validate.ts +0 -794
  199. package/scripts/validate.ts +0 -90
  200. package/skills/nx-init/meta.yml +0 -8
  201. package/skills/nx-plan/meta.yml +0 -10
  202. package/skills/nx-run/meta.yml +0 -9
  203. package/skills/nx-sync/meta.yml +0 -7
  204. package/vocabulary/capabilities.yml +0 -65
  205. package/vocabulary/categories.yml +0 -11
  206. package/vocabulary/invocations.yml +0 -147
  207. package/vocabulary/memory_policy.yml +0 -88
  208. package/vocabulary/resume-tiers.yml +0 -11
  209. package/vocabulary/tags.yml +0 -60
  210. package/vocabulary/task-exceptions.yml +0 -29
@@ -0,0 +1,801 @@
1
+ /**
2
+ * prompt-router/handler.test.ts
3
+ *
4
+ * Verifies all 11 tag handlers × state combinations described in Task #18.
5
+ * Uses bun:test. Fixtures placed in a tmp directory per describe block.
6
+ */
7
+
8
+ import { test, expect, describe, beforeAll, afterAll, beforeEach, afterEach } from "bun:test";
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import type { NexusHookInput, NexusHookOutput } from "../../../src/hooks/types.js";
13
+ import handler from "./handler.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Fixture helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** Root tmp dir shared across all tests in this module. */
20
+ let ROOT: string;
21
+ const SESSION_ID = "test-session-001";
22
+
23
+ function sessionDir(): string {
24
+ return path.join(ROOT, ".nexus", "state", SESSION_ID);
25
+ }
26
+
27
+ function planPath(): string {
28
+ return path.join(sessionDir(), "plan.json");
29
+ }
30
+
31
+ function tasksPath(): string {
32
+ return path.join(sessionDir(), "tasks.json");
33
+ }
34
+
35
+ function ensureSessionDir(): void {
36
+ fs.mkdirSync(sessionDir(), { recursive: true });
37
+ }
38
+
39
+ function writePlan(topic = "test-topic", pendingCount = 2): void {
40
+ ensureSessionDir();
41
+ const issues = [
42
+ ...Array.from({ length: pendingCount }, (_, i) => ({
43
+ id: `I${i + 1}`,
44
+ status: "pending",
45
+ })),
46
+ { id: "I99", status: "completed" },
47
+ ];
48
+ fs.writeFileSync(planPath(), JSON.stringify({ topic, issues }));
49
+ }
50
+
51
+ function writeTasks(pendingCount = 3): void {
52
+ ensureSessionDir();
53
+ const tasks = [
54
+ ...Array.from({ length: pendingCount }, (_, i) => ({
55
+ id: `T${i + 1}`,
56
+ status: "open",
57
+ })),
58
+ { id: "T99", status: "completed" },
59
+ ];
60
+ fs.writeFileSync(tasksPath(), JSON.stringify({ tasks }));
61
+ }
62
+
63
+ function removePlan(): void {
64
+ try {
65
+ fs.unlinkSync(planPath());
66
+ } catch {
67
+ // already absent
68
+ }
69
+ }
70
+
71
+ function removeTasks(): void {
72
+ try {
73
+ fs.unlinkSync(tasksPath());
74
+ } catch {
75
+ // already absent
76
+ }
77
+ }
78
+
79
+ function makeInput(prompt: string): NexusHookInput {
80
+ return {
81
+ hook_event_name: "UserPromptSubmit",
82
+ session_id: SESSION_ID,
83
+ cwd: ROOT,
84
+ prompt,
85
+ };
86
+ }
87
+
88
+ /** Assert output has additional_context and does NOT block. */
89
+ function assertNotice(
90
+ result: NexusHookOutput | void,
91
+ expected: RegExp | string
92
+ ): void {
93
+ expect(result).not.toBeUndefined();
94
+ const r = result as NexusHookOutput;
95
+ expect(r.decision).toBeUndefined();
96
+ expect(r.additional_context).toMatch(expected);
97
+ }
98
+
99
+ /** Assert output is a block decision. */
100
+ function assertBlock(
101
+ result: NexusHookOutput | void,
102
+ reasonPattern?: RegExp | string
103
+ ): void {
104
+ expect(result).not.toBeUndefined();
105
+ const r = result as NexusHookOutput;
106
+ expect(r.decision).toBe("block");
107
+ if (reasonPattern) {
108
+ expect(r.block_reason).toMatch(reasonPattern);
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Setup / Teardown
114
+ // ---------------------------------------------------------------------------
115
+
116
+ beforeAll(() => {
117
+ ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "nexus-prompt-router-"));
118
+ // Create mock agent and skill directories so loadValidRuleTargets works.
119
+ for (const name of ["architect", "engineer", "reviewer"]) {
120
+ fs.mkdirSync(path.join(ROOT, "assets", "agents", name), { recursive: true });
121
+ }
122
+ for (const name of ["nx-plan", "nx-run", "nx-sync", "nx-init"]) {
123
+ fs.mkdirSync(path.join(ROOT, "assets", "skills", name), { recursive: true });
124
+ }
125
+ // Ensure session dir exists (plan/tasks written per-test).
126
+ ensureSessionDir();
127
+ });
128
+
129
+ afterAll(() => {
130
+ fs.rmSync(ROOT, { recursive: true, force: true });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Scenario 1 — [plan] alone → nx-plan skill, no "auto" args
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("scenario 1 — [plan] alone", () => {
138
+ test("additional_context mentions nx-plan", async () => {
139
+ removePlan();
140
+ removeTasks();
141
+ const result = await handler(makeInput("[plan] structured planning"));
142
+ assertNotice(result, /nx-plan/);
143
+ assertNotice(result, /<system-notice>/);
144
+ });
145
+
146
+ test("does NOT mention 'auto'", async () => {
147
+ const result = await handler(makeInput("[plan] structured planning")) as NexusHookOutput;
148
+ expect(result?.additional_context).not.toMatch(/auto/);
149
+ });
150
+ });
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Scenario 2 — [plan:auto] → nx-plan + "auto", prioritised over [plan]
154
+ // ---------------------------------------------------------------------------
155
+
156
+ describe("scenario 2 — [plan:auto] priority over [plan]", () => {
157
+ test('[plan:auto] context includes "auto"', async () => {
158
+ removePlan();
159
+ removeTasks();
160
+ const result = await handler(makeInput("[plan:auto] auto planning"));
161
+ assertNotice(result, /auto/);
162
+ });
163
+
164
+ test("[plan:auto] is dispatched even when [plan] also appears in prompt", async () => {
165
+ // [plan:auto] appears first → base "plan" is locked → [plan] variant skipped
166
+ const result = await handler(
167
+ makeInput("[plan:auto] [plan] do not double-dispatch")
168
+ ) as NexusHookOutput;
169
+ // Should mention auto (from plan:auto) exactly once
170
+ const ctx = result?.additional_context ?? "";
171
+ expect((ctx.match(/plan:auto/g) ?? []).length).toBe(1);
172
+ // Must NOT also contain a plain [plan] notice (no second system-notice for plain plan)
173
+ expect((ctx.match(/<system-notice>/g) ?? []).length).toBe(1);
174
+ });
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Scenario 3 — [run] + tasks.json present → nx-run
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe("scenario 3 — [run] with tasks.json present", () => {
182
+ test("invokes nx-run skill", async () => {
183
+ removePlan();
184
+ writeTasks();
185
+ const result = await handler(makeInput("[run] execute tasks"));
186
+ assertNotice(result, /nx-run/);
187
+ });
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Scenario 4 — [run] + tasks.json absent → nx-plan auto guidance
192
+ // ---------------------------------------------------------------------------
193
+
194
+ describe("scenario 4 — [run] without tasks.json", () => {
195
+ test("recommends nx-plan auto first", async () => {
196
+ removePlan();
197
+ removeTasks();
198
+ const result = await handler(makeInput("[run] go"));
199
+ assertNotice(result, /nx-plan/);
200
+ assertNotice(result, /auto/);
201
+ // Must NOT directly invoke nx-run
202
+ const r = result as NexusHookOutput;
203
+ expect(r.additional_context).not.toMatch(/Invoke `nx-run`/);
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Scenario 5 — [d] + plan.json present → nx_plan_decide guidance
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe("scenario 5 — [d] with active plan", () => {
212
+ test("guides to nx_plan_decide", async () => {
213
+ writePlan();
214
+ removeTasks();
215
+ const result = await handler(makeInput("[d] decide now"));
216
+ assertNotice(result, /nx_plan_decide/);
217
+ });
218
+ });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Scenario 6 — [d] + plan.json absent → decision:block
222
+ // ---------------------------------------------------------------------------
223
+
224
+ describe("scenario 6 — [d] without plan — decision:block", () => {
225
+ test("blocks with reason about nx-plan", async () => {
226
+ removePlan();
227
+ removeTasks();
228
+ const result = await handler(makeInput("[d] decide"));
229
+ assertBlock(result, /nx-plan/);
230
+ });
231
+
232
+ test("block_reason is English text", async () => {
233
+ removePlan();
234
+ removeTasks();
235
+ const result = await handler(makeInput("[d] decide")) as NexusHookOutput;
236
+ // Must be a non-empty English string (ASCII)
237
+ expect(result.block_reason).toBeTruthy();
238
+ expect(result.block_reason).toMatch(/[a-zA-Z]/);
239
+ });
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Scenario 7 — [m] → memory notice, distinct from [m:gc]
244
+ // ---------------------------------------------------------------------------
245
+
246
+ describe("scenario 7 — [m] memory save", () => {
247
+ test("mentions .nexus/memory/ save guidance", async () => {
248
+ removePlan();
249
+ removeTasks();
250
+ const result = await handler(makeInput("[m] remember this"));
251
+ assertNotice(result, /\.nexus\/memory\//);
252
+ });
253
+
254
+ test("[m] notice does NOT mention 'consolidate' (that's [m:gc])", async () => {
255
+ const result = await handler(makeInput("[m] remember this")) as NexusHookOutput;
256
+ expect(result.additional_context).not.toMatch(/consolidate/);
257
+ });
258
+ });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Scenario 8 — [m:gc] → gc guidance, distinct from [m]
262
+ // ---------------------------------------------------------------------------
263
+
264
+ describe("scenario 8 — [m:gc] garbage-collect", () => {
265
+ test("mentions consolidate or stale entry cleanup", async () => {
266
+ removePlan();
267
+ removeTasks();
268
+ const result = await handler(makeInput("[m:gc] clean memory"));
269
+ assertNotice(result, /consolidate|stale/i);
270
+ });
271
+
272
+ test("[m:gc] notice does NOT reference prefix/pattern (that's [m])", async () => {
273
+ const result = await handler(makeInput("[m:gc] clean memory")) as NexusHookOutput;
274
+ // [m] notice mentions "Prefix:" — [m:gc] should not
275
+ expect(result.additional_context).not.toMatch(/Prefix:/);
276
+ });
277
+ });
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Scenario 9 — [rule] alone → valid targets listed
281
+ // ---------------------------------------------------------------------------
282
+
283
+ describe("scenario 9 — [rule] without name", () => {
284
+ test("lists valid targets from agents + skills dirs", async () => {
285
+ removePlan();
286
+ removeTasks();
287
+ const result = await handler(makeInput("[rule] add a new rule"));
288
+ // Our fixtures: architect, engineer, reviewer, nx-plan, nx-run, nx-sync, nx-init
289
+ assertNotice(result, /architect/);
290
+ assertNotice(result, /nx-plan/);
291
+ });
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Scenario 10 — [rule:valid_name] → update guidance
296
+ // ---------------------------------------------------------------------------
297
+
298
+ describe("scenario 10 — [rule:architect] valid target", () => {
299
+ test("guides to update .nexus/rules/architect.md", async () => {
300
+ removePlan();
301
+ removeTasks();
302
+ const result = await handler(makeInput("[rule:architect] new rule content"));
303
+ assertNotice(result, /\.nexus\/rules\/architect\.md/);
304
+ });
305
+ });
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Scenario 11 — [rule:invalid_name] → decision:block
309
+ // ---------------------------------------------------------------------------
310
+
311
+ describe("scenario 11 — [rule:nonexistent] invalid target — decision:block", () => {
312
+ test("blocks with reason listing valid targets", async () => {
313
+ removePlan();
314
+ removeTasks();
315
+ const result = await handler(makeInput("[rule:nonexistent] bad rule"));
316
+ assertBlock(result, /nonexistent/);
317
+ });
318
+
319
+ test("block_reason mentions valid alternatives", async () => {
320
+ const result = await handler(
321
+ makeInput("[rule:nonexistent] bad rule")
322
+ ) as NexusHookOutput;
323
+ // Should mention at least one valid target
324
+ expect(result.block_reason).toMatch(/architect|engineer|nx-plan/);
325
+ });
326
+ });
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Scenario 12 — [sync] → nx-sync guidance
330
+ // ---------------------------------------------------------------------------
331
+
332
+ describe("scenario 12 — [sync]", () => {
333
+ test("invokes nx-sync skill guidance", async () => {
334
+ removePlan();
335
+ removeTasks();
336
+ const result = await handler(makeInput("[sync] synchronize context"));
337
+ assertNotice(result, /nx-sync/);
338
+ assertNotice(result, /\.nexus\/context\//);
339
+ });
340
+ });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Scenario 13 — [init] vs [init:reset] distinction
344
+ // ---------------------------------------------------------------------------
345
+
346
+ describe("scenario 13 — [init] and [init:reset] distinction", () => {
347
+ test("[init] mentions nx-init for onboarding", async () => {
348
+ removePlan();
349
+ removeTasks();
350
+ const result = await handler(makeInput("[init] project setup"));
351
+ assertNotice(result, /nx-init/);
352
+ // Must NOT say "reset"
353
+ const r = result as NexusHookOutput;
354
+ expect(r.additional_context).not.toMatch(/reset/);
355
+ });
356
+
357
+ test("[init:reset] includes 'reset' args", async () => {
358
+ removePlan();
359
+ removeTasks();
360
+ const result = await handler(makeInput("[init:reset] full reset"));
361
+ assertNotice(result, /reset/);
362
+ });
363
+
364
+ test("[init:reset] is dispatched instead of [init] when both appear", async () => {
365
+ // [init:reset] appears first in TAG_PATTERNS → base "init" seen → [init] skipped
366
+ const result = await handler(
367
+ makeInput("[init:reset] [init] should not double")
368
+ ) as NexusHookOutput;
369
+ const ctx = result?.additional_context ?? "";
370
+ expect((ctx.match(/<system-notice>/g) ?? []).length).toBe(1);
371
+ });
372
+ });
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Scenario 14 — multiple tags in single prompt ([plan] [m])
376
+ // ---------------------------------------------------------------------------
377
+
378
+ describe("scenario 14 — multiple tags processed together", () => {
379
+ test("[plan] and [m] both produce notices", async () => {
380
+ removePlan();
381
+ removeTasks();
382
+ const result = await handler(
383
+ makeInput("[plan] create plan [m] remember this lesson")
384
+ ) as NexusHookOutput;
385
+ expect(result).not.toBeUndefined();
386
+ expect(result.decision).toBeUndefined();
387
+ const ctx = result.additional_context ?? "";
388
+ expect(ctx).toMatch(/nx-plan/);
389
+ expect(ctx).toMatch(/\.nexus\/memory\//);
390
+ // Two system-notice blocks
391
+ expect((ctx.match(/<system-notice>/g) ?? []).length).toBe(2);
392
+ });
393
+ });
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Scenario 15 — no tags + active plan.json → state notice
397
+ // ---------------------------------------------------------------------------
398
+
399
+ describe("scenario 15 — no tags with active plan.json", () => {
400
+ test("emits active plan session notice", async () => {
401
+ writePlan("my-feature", 3);
402
+ removeTasks();
403
+ const result = await handler(makeInput("just a regular message"));
404
+ assertNotice(result, /my-feature/);
405
+ assertNotice(result, /pending/);
406
+ });
407
+ });
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Scenario 16 — no tags + no plan.json + no tasks.json → no output
411
+ // ---------------------------------------------------------------------------
412
+
413
+ describe("scenario 16 — no tags, no state files", () => {
414
+ test("returns undefined (no output)", async () => {
415
+ removePlan();
416
+ removeTasks();
417
+ const result = await handler(makeInput("just chatting, no tags"));
418
+ expect(result).toBeUndefined();
419
+ });
420
+ });
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // Scenario 17 — all notices are English + wrapped in <system-notice>
424
+ // ---------------------------------------------------------------------------
425
+
426
+ describe("scenario 17 — all notices are English and use <system-notice>", () => {
427
+ const tagCases: Array<[string, string]> = [
428
+ ["[plan]", "[plan] do something"],
429
+ ["[plan:auto]", "[plan:auto] do auto"],
430
+ ["[run] with tasks", "[run] run tasks"], // will have tasks.json
431
+ ["[m]", "[m] save this"],
432
+ ["[m:gc]", "[m:gc] gc"],
433
+ ["[rule]", "[rule] add rule"],
434
+ ["[rule:architect]", "[rule:architect] update"],
435
+ ["[sync]", "[sync] sync now"],
436
+ ["[init]", "[init] project"],
437
+ ["[init:reset]", "[init:reset] reset"],
438
+ ];
439
+
440
+ for (const [label, prompt] of tagCases) {
441
+ test(`${label} — notice is English + <system-notice> wrapped`, async () => {
442
+ if (label === "[run] with tasks") {
443
+ writeTasks();
444
+ } else {
445
+ removeTasks();
446
+ }
447
+ removePlan();
448
+
449
+ const result = await handler(makeInput(prompt));
450
+ // Must return something (not undefined, not block for these cases)
451
+ expect(result).not.toBeUndefined();
452
+ const r = result as NexusHookOutput;
453
+
454
+ // For non-blocking cases: check additional_context
455
+ if (r.decision !== "block") {
456
+ const ctx = r.additional_context ?? "";
457
+ expect(ctx).toMatch(/<system-notice>/);
458
+ expect(ctx).toMatch(/<\/system-notice>/);
459
+ // Must be primarily ASCII English (no Korean/CJK characters)
460
+ expect(ctx).not.toMatch(/[\u4e00-\u9fff\uac00-\ud7a3\u3040-\u30ff]/);
461
+ }
462
+ });
463
+ }
464
+ });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Regex priority verification
468
+ // ---------------------------------------------------------------------------
469
+
470
+ describe("regex priority — specific variants win over generic", () => {
471
+ test("[plan:auto] wins over [plan] — base 'plan' not double-dispatched", async () => {
472
+ removePlan();
473
+ removeTasks();
474
+ const result = await handler(makeInput("[plan:auto] [plan]")) as NexusHookOutput;
475
+ // Only one system-notice emitted (plan:auto wins; plain plan skipped)
476
+ const ctx = result?.additional_context ?? "";
477
+ expect((ctx.match(/<system-notice>/g) ?? []).length).toBe(1);
478
+ expect(ctx).toMatch(/plan:auto/);
479
+ });
480
+
481
+ test("[m:gc] wins over [m] when both appear", async () => {
482
+ removePlan();
483
+ removeTasks();
484
+ const result = await handler(makeInput("[m:gc] [m]")) as NexusHookOutput;
485
+ const ctx = result?.additional_context ?? "";
486
+ expect((ctx.match(/<system-notice>/g) ?? []).length).toBe(1);
487
+ expect(ctx).toMatch(/m:gc/);
488
+ expect(ctx).not.toMatch(/Prefix:/);
489
+ });
490
+
491
+ test("[init:reset] wins over [init] when both appear", async () => {
492
+ removePlan();
493
+ removeTasks();
494
+ const result = await handler(makeInput("[init:reset] [init]")) as NexusHookOutput;
495
+ const ctx = result?.additional_context ?? "";
496
+ expect((ctx.match(/<system-notice>/g) ?? []).length).toBe(1);
497
+ expect(ctx).toMatch(/reset/);
498
+ });
499
+ });
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Non-UserPromptSubmit events are ignored
503
+ // ---------------------------------------------------------------------------
504
+
505
+ describe("non-UserPromptSubmit events are ignored", () => {
506
+ test("SessionStart returns undefined", async () => {
507
+ const input: NexusHookInput = {
508
+ hook_event_name: "SessionStart",
509
+ session_id: SESSION_ID,
510
+ cwd: ROOT,
511
+ };
512
+ const result = await handler(input);
513
+ expect(result).toBeUndefined();
514
+ });
515
+ });
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // Harness matrix — 3 harnesses × primary tag cases (invocations SSOT)
519
+ // ---------------------------------------------------------------------------
520
+ // Expected expanded strings per harness (skill_activation template):
521
+ // claude: Skill({ command: "<skill>" })
522
+ // opencode: skill({ name: "<skill>" })
523
+ // codex: $<skill>
524
+
525
+ describe("harness matrix — claude", () => {
526
+ let _savedHarness: string | undefined;
527
+
528
+ beforeEach(() => {
529
+ _savedHarness = process.env["NEXUS_HARNESS"];
530
+ process.env["NEXUS_HARNESS"] = "claude";
531
+ });
532
+
533
+ afterEach(() => {
534
+ if (_savedHarness === undefined) {
535
+ delete process.env["NEXUS_HARNESS"];
536
+ } else {
537
+ process.env["NEXUS_HARNESS"] = _savedHarness;
538
+ }
539
+ });
540
+
541
+ test("[plan] → Skill({ command: \"nx-plan\" })", async () => {
542
+ removePlan();
543
+ removeTasks();
544
+ const result = await handler(makeInput("[plan] go")) as NexusHookOutput;
545
+ expect(result.additional_context).toMatch(/Skill\(\{ command: "nx-plan" \}\)/);
546
+ });
547
+
548
+ test("[plan:auto] → Skill({ command: \"nx-plan\" }) + auto in tag text", async () => {
549
+ removePlan();
550
+ removeTasks();
551
+ const result = await handler(makeInput("[plan:auto] go")) as NexusHookOutput;
552
+ expect(result.additional_context).toMatch(/Skill\(\{ command: "nx-plan" \}\)/);
553
+ expect(result.additional_context).toMatch(/auto/);
554
+ });
555
+
556
+ test("[run] with tasks → Skill({ command: \"nx-run\" })", async () => {
557
+ removePlan();
558
+ writeTasks();
559
+ const result = await handler(makeInput("[run] go")) as NexusHookOutput;
560
+ expect(result.additional_context).toMatch(/Skill\(\{ command: "nx-run" \}\)/);
561
+ });
562
+
563
+ test("[run] no tasks → Skill({ command: \"nx-plan\" }) + auto", async () => {
564
+ removePlan();
565
+ removeTasks();
566
+ const result = await handler(makeInput("[run] go")) as NexusHookOutput;
567
+ expect(result.additional_context).toMatch(/Skill\(\{ command: "nx-plan" \}\)/);
568
+ expect(result.additional_context).toMatch(/auto/);
569
+ });
570
+
571
+ test("[sync] → Skill({ command: \"nx-sync\" })", async () => {
572
+ removePlan();
573
+ removeTasks();
574
+ const result = await handler(makeInput("[sync] go")) as NexusHookOutput;
575
+ expect(result.additional_context).toMatch(/Skill\(\{ command: "nx-sync" \}\)/);
576
+ });
577
+
578
+ test("[init] → Skill({ command: \"nx-init\" })", async () => {
579
+ removePlan();
580
+ removeTasks();
581
+ const result = await handler(makeInput("[init] go")) as NexusHookOutput;
582
+ expect(result.additional_context).toMatch(/Skill\(\{ command: "nx-init" \}\)/);
583
+ });
584
+
585
+ test("[init:reset] → Skill({ command: \"nx-init\" }) + reset in tag text", async () => {
586
+ removePlan();
587
+ removeTasks();
588
+ const result = await handler(makeInput("[init:reset] go")) as NexusHookOutput;
589
+ expect(result.additional_context).toMatch(/Skill\(\{ command: "nx-init" \}\)/);
590
+ expect(result.additional_context).toMatch(/reset/);
591
+ });
592
+ });
593
+
594
+ describe("harness matrix — opencode", () => {
595
+ let _savedHarness: string | undefined;
596
+
597
+ beforeEach(() => {
598
+ _savedHarness = process.env["NEXUS_HARNESS"];
599
+ process.env["NEXUS_HARNESS"] = "opencode";
600
+ });
601
+
602
+ afterEach(() => {
603
+ if (_savedHarness === undefined) {
604
+ delete process.env["NEXUS_HARNESS"];
605
+ } else {
606
+ process.env["NEXUS_HARNESS"] = _savedHarness;
607
+ }
608
+ });
609
+
610
+ test("[plan] → skill({ name: \"nx-plan\" })", async () => {
611
+ removePlan();
612
+ removeTasks();
613
+ const result = await handler(makeInput("[plan] go")) as NexusHookOutput;
614
+ expect(result.additional_context).toMatch(/skill\(\{ name: "nx-plan" \}\)/);
615
+ });
616
+
617
+ test("[plan:auto] → skill({ name: \"nx-plan\" }) + auto", async () => {
618
+ removePlan();
619
+ removeTasks();
620
+ const result = await handler(makeInput("[plan:auto] go")) as NexusHookOutput;
621
+ expect(result.additional_context).toMatch(/skill\(\{ name: "nx-plan" \}\)/);
622
+ expect(result.additional_context).toMatch(/auto/);
623
+ });
624
+
625
+ test("[run] with tasks → skill({ name: \"nx-run\" })", async () => {
626
+ removePlan();
627
+ writeTasks();
628
+ const result = await handler(makeInput("[run] go")) as NexusHookOutput;
629
+ expect(result.additional_context).toMatch(/skill\(\{ name: "nx-run" \}\)/);
630
+ });
631
+
632
+ test("[run] no tasks → skill({ name: \"nx-plan\" }) + auto", async () => {
633
+ removePlan();
634
+ removeTasks();
635
+ const result = await handler(makeInput("[run] go")) as NexusHookOutput;
636
+ expect(result.additional_context).toMatch(/skill\(\{ name: "nx-plan" \}\)/);
637
+ expect(result.additional_context).toMatch(/auto/);
638
+ });
639
+
640
+ test("[sync] → skill({ name: \"nx-sync\" })", async () => {
641
+ removePlan();
642
+ removeTasks();
643
+ const result = await handler(makeInput("[sync] go")) as NexusHookOutput;
644
+ expect(result.additional_context).toMatch(/skill\(\{ name: "nx-sync" \}\)/);
645
+ });
646
+
647
+ test("[init] → skill({ name: \"nx-init\" })", async () => {
648
+ removePlan();
649
+ removeTasks();
650
+ const result = await handler(makeInput("[init] go")) as NexusHookOutput;
651
+ expect(result.additional_context).toMatch(/skill\(\{ name: "nx-init" \}\)/);
652
+ });
653
+
654
+ test("[init:reset] → skill({ name: \"nx-init\" }) + reset", async () => {
655
+ removePlan();
656
+ removeTasks();
657
+ const result = await handler(makeInput("[init:reset] go")) as NexusHookOutput;
658
+ expect(result.additional_context).toMatch(/skill\(\{ name: "nx-init" \}\)/);
659
+ expect(result.additional_context).toMatch(/reset/);
660
+ });
661
+ });
662
+
663
+ describe("harness matrix — codex", () => {
664
+ let _savedHarness: string | undefined;
665
+
666
+ beforeEach(() => {
667
+ _savedHarness = process.env["NEXUS_HARNESS"];
668
+ process.env["NEXUS_HARNESS"] = "codex";
669
+ });
670
+
671
+ afterEach(() => {
672
+ if (_savedHarness === undefined) {
673
+ delete process.env["NEXUS_HARNESS"];
674
+ } else {
675
+ process.env["NEXUS_HARNESS"] = _savedHarness;
676
+ }
677
+ });
678
+
679
+ test("[plan] → $nx-plan", async () => {
680
+ removePlan();
681
+ removeTasks();
682
+ const result = await handler(makeInput("[plan] go")) as NexusHookOutput;
683
+ expect(result.additional_context).toMatch(/\$nx-plan/);
684
+ });
685
+
686
+ test("[plan:auto] → $nx-plan + auto in tag text", async () => {
687
+ removePlan();
688
+ removeTasks();
689
+ const result = await handler(makeInput("[plan:auto] go")) as NexusHookOutput;
690
+ expect(result.additional_context).toMatch(/\$nx-plan/);
691
+ expect(result.additional_context).toMatch(/auto/);
692
+ });
693
+
694
+ test("[run] with tasks → $nx-run", async () => {
695
+ removePlan();
696
+ writeTasks();
697
+ const result = await handler(makeInput("[run] go")) as NexusHookOutput;
698
+ expect(result.additional_context).toMatch(/\$nx-run/);
699
+ });
700
+
701
+ test("[run] no tasks → $nx-plan + auto", async () => {
702
+ removePlan();
703
+ removeTasks();
704
+ const result = await handler(makeInput("[run] go")) as NexusHookOutput;
705
+ expect(result.additional_context).toMatch(/\$nx-plan/);
706
+ expect(result.additional_context).toMatch(/auto/);
707
+ });
708
+
709
+ test("[sync] → $nx-sync", async () => {
710
+ removePlan();
711
+ removeTasks();
712
+ const result = await handler(makeInput("[sync] go")) as NexusHookOutput;
713
+ expect(result.additional_context).toMatch(/\$nx-sync/);
714
+ });
715
+
716
+ test("[init] → $nx-init", async () => {
717
+ removePlan();
718
+ removeTasks();
719
+ const result = await handler(makeInput("[init] go")) as NexusHookOutput;
720
+ expect(result.additional_context).toMatch(/\$nx-init/);
721
+ });
722
+
723
+ test("[init:reset] → $nx-init + reset in tag text", async () => {
724
+ removePlan();
725
+ removeTasks();
726
+ const result = await handler(makeInput("[init:reset] go")) as NexusHookOutput;
727
+ expect(result.additional_context).toMatch(/\$nx-init/);
728
+ expect(result.additional_context).toMatch(/reset/);
729
+ });
730
+ });
731
+
732
+ // ---------------------------------------------------------------------------
733
+ // 모듈 전역 상태 격리
734
+ // ---------------------------------------------------------------------------
735
+
736
+ describe("모듈 전역 상태 격리", () => {
737
+ function makePromptInput(cwd: string, prompt: string): NexusHookInput {
738
+ return {
739
+ hook_event_name: "UserPromptSubmit",
740
+ session_id: "sess-isolation",
741
+ cwd,
742
+ prompt,
743
+ };
744
+ }
745
+
746
+ // Test A: two cwds with different agents/skills → each sees only its own targets
747
+ test("Test A: different cwds return only their own valid rule targets", async () => {
748
+ const tmpDir1 = fs.mkdtempSync(path.join(os.tmpdir(), "nexus-pr-isolation-a1-"));
749
+ const tmpDir2 = fs.mkdtempSync(path.join(os.tmpdir(), "nexus-pr-isolation-a2-"));
750
+
751
+ try {
752
+ // tmpDir1: agent "architect" only
753
+ fs.mkdirSync(path.join(tmpDir1, "assets/agents/architect"), { recursive: true });
754
+
755
+ // tmpDir2: agent "engineer" only
756
+ fs.mkdirSync(path.join(tmpDir2, "assets/agents/engineer"), { recursive: true });
757
+
758
+ // [rule:architect] is valid in tmpDir1, invalid in tmpDir2
759
+ const r1 = await handler(makePromptInput(tmpDir1, "[rule:architect] new rule")) as NexusHookOutput;
760
+ expect(r1.decision).toBeUndefined();
761
+ expect(r1.additional_context).toMatch(/architect/);
762
+
763
+ const r2 = await handler(makePromptInput(tmpDir2, "[rule:architect] new rule")) as NexusHookOutput;
764
+ expect(r2.decision).toBe("block");
765
+
766
+ // [rule:engineer] is invalid in tmpDir1, valid in tmpDir2
767
+ const r3 = await handler(makePromptInput(tmpDir1, "[rule:engineer] new rule")) as NexusHookOutput;
768
+ expect(r3.decision).toBe("block");
769
+
770
+ const r4 = await handler(makePromptInput(tmpDir2, "[rule:engineer] new rule")) as NexusHookOutput;
771
+ expect(r4.decision).toBeUndefined();
772
+ expect(r4.additional_context).toMatch(/engineer/);
773
+ } finally {
774
+ fs.rmSync(tmpDir1, { recursive: true, force: true });
775
+ fs.rmSync(tmpDir2, { recursive: true, force: true });
776
+ }
777
+ });
778
+
779
+ // Test B: newly added directory is reflected on the next call
780
+ test("Test B: newly added agent directory is recognized in subsequent call", async () => {
781
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nexus-pr-isolation-b-"));
782
+
783
+ try {
784
+ // Start with "architect" only — "foo" is not a valid target yet
785
+ fs.mkdirSync(path.join(tmpDir, "assets/agents/architect"), { recursive: true });
786
+
787
+ const r1 = await handler(makePromptInput(tmpDir, "[rule:foo] test")) as NexusHookOutput;
788
+ expect(r1.decision).toBe("block");
789
+
790
+ // Add "foo" agent directory
791
+ fs.mkdirSync(path.join(tmpDir, "assets/agents/foo"), { recursive: true });
792
+
793
+ // Next call should see "foo" as a valid target
794
+ const r2 = await handler(makePromptInput(tmpDir, "[rule:foo] test")) as NexusHookOutput;
795
+ expect(r2.decision).toBeUndefined();
796
+ expect(r2.additional_context).toMatch(/foo/);
797
+ } finally {
798
+ fs.rmSync(tmpDir, { recursive: true, force: true });
799
+ }
800
+ });
801
+ });