@moreih29/nexus-core 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +48 -63
  2. package/assets/agents/architect/body.ko.md +177 -0
  3. package/{agents → assets/agents}/architect/body.md +16 -0
  4. package/assets/agents/designer/body.ko.md +125 -0
  5. package/{agents → assets/agents}/designer/body.md +16 -0
  6. package/assets/agents/engineer/body.ko.md +106 -0
  7. package/{agents → assets/agents}/engineer/body.md +14 -0
  8. package/assets/agents/lead/body.ko.md +70 -0
  9. package/assets/agents/lead/body.md +70 -0
  10. package/assets/agents/postdoc/body.ko.md +122 -0
  11. package/{agents → assets/agents}/postdoc/body.md +16 -0
  12. package/assets/agents/researcher/body.ko.md +137 -0
  13. package/{agents → assets/agents}/researcher/body.md +15 -0
  14. package/assets/agents/reviewer/body.ko.md +138 -0
  15. package/{agents → assets/agents}/reviewer/body.md +15 -0
  16. package/assets/agents/strategist/body.ko.md +116 -0
  17. package/{agents → assets/agents}/strategist/body.md +16 -0
  18. package/assets/agents/tester/body.ko.md +195 -0
  19. package/{agents → assets/agents}/tester/body.md +15 -0
  20. package/assets/agents/writer/body.ko.md +122 -0
  21. package/{agents → assets/agents}/writer/body.md +14 -0
  22. package/assets/capability-matrix.yml +198 -0
  23. package/assets/hooks/agent-bootstrap/handler.test.ts +368 -0
  24. package/assets/hooks/agent-bootstrap/handler.ts +119 -0
  25. package/assets/hooks/agent-bootstrap/meta.yml +10 -0
  26. package/assets/hooks/agent-finalize/handler.test.ts +368 -0
  27. package/assets/hooks/agent-finalize/handler.ts +76 -0
  28. package/assets/hooks/agent-finalize/meta.yml +10 -0
  29. package/assets/hooks/capability-matrix.yml +313 -0
  30. package/assets/hooks/post-tool-telemetry/handler.test.ts +302 -0
  31. package/assets/hooks/post-tool-telemetry/handler.ts +49 -0
  32. package/assets/hooks/post-tool-telemetry/meta.yml +11 -0
  33. package/assets/hooks/prompt-router/handler.test.ts +801 -0
  34. package/assets/hooks/prompt-router/handler.ts +261 -0
  35. package/assets/hooks/prompt-router/meta.yml +11 -0
  36. package/assets/hooks/session-init/handler.test.ts +274 -0
  37. package/assets/hooks/session-init/handler.ts +30 -0
  38. package/assets/hooks/session-init/meta.yml +9 -0
  39. package/assets/lsp-servers.json +55 -0
  40. package/assets/schema/lsp-servers.schema.json +67 -0
  41. package/assets/skills/nx-init/body.ko.md +197 -0
  42. package/{skills → assets/skills}/nx-init/body.md +11 -0
  43. package/assets/skills/nx-plan/body.ko.md +361 -0
  44. package/{skills → assets/skills}/nx-plan/body.md +13 -0
  45. package/assets/skills/nx-run/body.ko.md +161 -0
  46. package/{skills → assets/skills}/nx-run/body.md +11 -0
  47. package/assets/skills/nx-sync/body.ko.md +92 -0
  48. package/{skills → assets/skills}/nx-sync/body.md +10 -0
  49. package/assets/tools/tool-name-map.yml +353 -0
  50. package/dist/hooks/opencode-mount.d.ts +35 -0
  51. package/dist/hooks/opencode-mount.d.ts.map +1 -0
  52. package/dist/hooks/opencode-mount.js +332 -0
  53. package/dist/hooks/opencode-mount.js.map +1 -0
  54. package/dist/hooks/runtime.d.ts +37 -0
  55. package/dist/hooks/runtime.d.ts.map +1 -0
  56. package/dist/hooks/runtime.js +274 -0
  57. package/dist/hooks/runtime.js.map +1 -0
  58. package/dist/hooks/types.d.ts +196 -0
  59. package/dist/hooks/types.d.ts.map +1 -0
  60. package/dist/hooks/types.js +85 -0
  61. package/dist/hooks/types.js.map +1 -0
  62. package/dist/lsp/cache.d.ts +9 -0
  63. package/dist/lsp/cache.d.ts.map +1 -0
  64. package/dist/lsp/cache.js +216 -0
  65. package/dist/lsp/cache.js.map +1 -0
  66. package/dist/lsp/client.d.ts +24 -0
  67. package/dist/lsp/client.d.ts.map +1 -0
  68. package/dist/lsp/client.js +166 -0
  69. package/dist/lsp/client.js.map +1 -0
  70. package/dist/lsp/detect.d.ts +77 -0
  71. package/dist/lsp/detect.d.ts.map +1 -0
  72. package/dist/lsp/detect.js +116 -0
  73. package/dist/lsp/detect.js.map +1 -0
  74. package/dist/mcp/server.d.ts +5 -0
  75. package/dist/mcp/server.d.ts.map +1 -0
  76. package/dist/mcp/server.js +34 -0
  77. package/dist/mcp/server.js.map +1 -0
  78. package/dist/mcp/tools/artifact.d.ts +4 -0
  79. package/dist/mcp/tools/artifact.d.ts.map +1 -0
  80. package/dist/mcp/tools/artifact.js +36 -0
  81. package/dist/mcp/tools/artifact.js.map +1 -0
  82. package/dist/mcp/tools/history.d.ts +3 -0
  83. package/dist/mcp/tools/history.d.ts.map +1 -0
  84. package/dist/mcp/tools/history.js +29 -0
  85. package/dist/mcp/tools/history.js.map +1 -0
  86. package/dist/mcp/tools/lsp.d.ts +13 -0
  87. package/dist/mcp/tools/lsp.d.ts.map +1 -0
  88. package/dist/mcp/tools/lsp.js +225 -0
  89. package/dist/mcp/tools/lsp.js.map +1 -0
  90. package/dist/mcp/tools/plan.d.ts +3 -0
  91. package/dist/mcp/tools/plan.d.ts.map +1 -0
  92. package/dist/mcp/tools/plan.js +317 -0
  93. package/dist/mcp/tools/plan.js.map +1 -0
  94. package/dist/mcp/tools/task.d.ts +3 -0
  95. package/dist/mcp/tools/task.d.ts.map +1 -0
  96. package/dist/mcp/tools/task.js +252 -0
  97. package/dist/mcp/tools/task.js.map +1 -0
  98. package/dist/shared/invocations.d.ts +74 -0
  99. package/dist/shared/invocations.d.ts.map +1 -0
  100. package/dist/shared/invocations.js +247 -0
  101. package/dist/shared/invocations.js.map +1 -0
  102. package/dist/shared/json-store.d.ts +37 -0
  103. package/dist/shared/json-store.d.ts.map +1 -0
  104. package/dist/shared/json-store.js +163 -0
  105. package/dist/shared/json-store.js.map +1 -0
  106. package/dist/shared/mcp-utils.d.ts +3 -0
  107. package/dist/shared/mcp-utils.d.ts.map +1 -0
  108. package/dist/shared/mcp-utils.js +6 -0
  109. package/dist/shared/mcp-utils.js.map +1 -0
  110. package/dist/shared/paths.d.ts +21 -0
  111. package/dist/shared/paths.d.ts.map +1 -0
  112. package/dist/shared/paths.js +81 -0
  113. package/dist/shared/paths.js.map +1 -0
  114. package/dist/shared/tool-log.d.ts +8 -0
  115. package/dist/shared/tool-log.d.ts.map +1 -0
  116. package/dist/shared/tool-log.js +22 -0
  117. package/dist/shared/tool-log.js.map +1 -0
  118. package/dist/types/state.d.ts +862 -0
  119. package/dist/types/state.d.ts.map +1 -0
  120. package/dist/types/state.js +66 -0
  121. package/dist/types/state.js.map +1 -0
  122. package/docs/consuming/codex-lead-merge.md +106 -0
  123. package/docs/plugin-guide.md +360 -0
  124. package/docs/plugin-template/claude/.github/workflows/build.yml +60 -0
  125. package/docs/plugin-template/claude/README.md +110 -0
  126. package/docs/plugin-template/claude/package.json +16 -0
  127. package/docs/plugin-template/codex/.github/workflows/build.yml +51 -0
  128. package/docs/plugin-template/codex/README.md +147 -0
  129. package/docs/plugin-template/codex/package.json +17 -0
  130. package/docs/plugin-template/opencode/.github/workflows/build.yml +61 -0
  131. package/docs/plugin-template/opencode/README.md +121 -0
  132. package/docs/plugin-template/opencode/package.json +25 -0
  133. package/package.json +21 -21
  134. package/scripts/build-agents.test.ts +1279 -0
  135. package/scripts/build-agents.ts +978 -0
  136. package/scripts/build-hooks.test.ts +1385 -0
  137. package/scripts/build-hooks.ts +584 -0
  138. package/scripts/cli.test.ts +367 -0
  139. package/scripts/cli.ts +547 -0
  140. package/agents/architect/meta.yml +0 -13
  141. package/agents/designer/meta.yml +0 -13
  142. package/agents/engineer/meta.yml +0 -11
  143. package/agents/postdoc/meta.yml +0 -13
  144. package/agents/researcher/meta.yml +0 -12
  145. package/agents/reviewer/meta.yml +0 -12
  146. package/agents/strategist/meta.yml +0 -13
  147. package/agents/tester/meta.yml +0 -12
  148. package/agents/writer/meta.yml +0 -11
  149. package/conformance/README.md +0 -311
  150. package/conformance/examples/plan.extension.schema.example.json +0 -25
  151. package/conformance/lifecycle/README.md +0 -48
  152. package/conformance/lifecycle/agent-complete.json +0 -44
  153. package/conformance/lifecycle/agent-resume.json +0 -43
  154. package/conformance/lifecycle/agent-spawn.json +0 -36
  155. package/conformance/lifecycle/memory-access-record.json +0 -27
  156. package/conformance/lifecycle/session-end.json +0 -48
  157. package/conformance/scenarios/full-plan-cycle.json +0 -147
  158. package/conformance/scenarios/task-deps-ordering.json +0 -95
  159. package/conformance/schema/fixture.schema.json +0 -354
  160. package/conformance/state-schemas/agent-tracker.schema.json +0 -63
  161. package/conformance/state-schemas/history.schema.json +0 -134
  162. package/conformance/state-schemas/memory-access.schema.json +0 -36
  163. package/conformance/state-schemas/plan.schema.json +0 -77
  164. package/conformance/state-schemas/tasks.schema.json +0 -98
  165. package/conformance/tools/artifact-write.json +0 -97
  166. package/conformance/tools/context.json +0 -172
  167. package/conformance/tools/history-search.json +0 -219
  168. package/conformance/tools/plan-decide.json +0 -139
  169. package/conformance/tools/plan-start.json +0 -81
  170. package/conformance/tools/plan-status.json +0 -127
  171. package/conformance/tools/plan-update.json +0 -341
  172. package/conformance/tools/task-add.json +0 -156
  173. package/conformance/tools/task-close.json +0 -161
  174. package/conformance/tools/task-list.json +0 -177
  175. package/conformance/tools/task-update.json +0 -167
  176. package/docs/behavioral-contracts.md +0 -145
  177. package/docs/consumer-implementation-guide.md +0 -840
  178. package/docs/memory-lifecycle-contract.md +0 -119
  179. package/docs/nexus-layout.md +0 -224
  180. package/docs/nexus-outputs-contract.md +0 -344
  181. package/docs/nexus-state-overview.md +0 -170
  182. package/docs/nexus-tools-contract.md +0 -438
  183. package/manifest.json +0 -448
  184. package/schema/README.md +0 -69
  185. package/schema/agent.schema.json +0 -23
  186. package/schema/common.schema.json +0 -17
  187. package/schema/manifest.schema.json +0 -78
  188. package/schema/memory-policy.schema.json +0 -98
  189. package/schema/skill.schema.json +0 -54
  190. package/schema/task-exceptions.schema.json +0 -40
  191. package/schema/vocabulary.schema.json +0 -167
  192. package/scripts/.gitkeep +0 -0
  193. package/scripts/conformance-coverage.ts +0 -466
  194. package/scripts/import-from-claude-nexus.ts +0 -403
  195. package/scripts/lib/frontmatter.ts +0 -71
  196. package/scripts/lib/lint.ts +0 -348
  197. package/scripts/lib/structure.ts +0 -159
  198. package/scripts/lib/validate.ts +0 -796
  199. package/scripts/validate.ts +0 -90
  200. package/skills/nx-init/meta.yml +0 -8
  201. package/skills/nx-plan/meta.yml +0 -10
  202. package/skills/nx-run/meta.yml +0 -8
  203. package/skills/nx-sync/meta.yml +0 -7
  204. package/vocabulary/capabilities.yml +0 -65
  205. package/vocabulary/categories.yml +0 -11
  206. package/vocabulary/invocations.yml +0 -147
  207. package/vocabulary/memory_policy.yml +0 -88
  208. package/vocabulary/resume-tiers.yml +0 -11
  209. package/vocabulary/tags.yml +0 -60
  210. package/vocabulary/task-exceptions.yml +0 -29
@@ -0,0 +1,261 @@
1
+ import type { HookHandler, NexusHookOutput } from "../../../src/hooks/types.js";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join, resolve, dirname } from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import {
6
+ expandInvocations,
7
+ type InvocationsMap,
8
+ type Harness,
9
+ } from "../../../src/shared/invocations.js";
10
+
11
+ // Tag priority: specific variants first (m:gc > m, rule:name > rule, plan:auto > plan, init:reset > init)
12
+ const TAG_PATTERNS: Array<{ name: string; regex: RegExp }> = [
13
+ { name: "plan:auto", regex: /\[plan:auto\]/ },
14
+ { name: "plan", regex: /\[plan\](?!\w)/ },
15
+ { name: "run", regex: /\[run\](?!\w)/ },
16
+ { name: "d", regex: /\[d\](?!\w)/ },
17
+ { name: "m:gc", regex: /\[m:gc\]/ },
18
+ { name: "m", regex: /\[m\](?!\w)/ },
19
+ { name: "rule:name", regex: /\[rule:([a-zA-Z0-9_-]+)\]/ },
20
+ { name: "rule", regex: /\[rule\](?!\w)/ },
21
+ { name: "sync", regex: /\[sync\](?!\w)/ },
22
+ { name: "init:reset", regex: /\[init:reset\]/ },
23
+ { name: "init", regex: /\[init\](?!\w)/ },
24
+ ];
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Invocations loader — cached per process
28
+ // ---------------------------------------------------------------------------
29
+
30
+ let _invocationsCache: InvocationsMap | null = null;
31
+
32
+ function loadInvocations(): InvocationsMap {
33
+ if (_invocationsCache) return _invocationsCache;
34
+
35
+ const selfDir = new URL(".", import.meta.url).pathname;
36
+ // Walk up from handler directory to find assets/tools/tool-name-map.yml
37
+ let dir = selfDir;
38
+ while (dir !== "/") {
39
+ const candidate = resolve(dir, "assets/tools/tool-name-map.yml");
40
+ if (existsSync(candidate)) {
41
+ const raw = readFileSync(candidate, "utf-8");
42
+ const parsed = parseYaml(raw) as { invocations?: InvocationsMap };
43
+ if (!parsed.invocations) {
44
+ throw new Error("[prompt-router] tool-name-map.yml missing 'invocations' section");
45
+ }
46
+ _invocationsCache = parsed.invocations;
47
+ return _invocationsCache;
48
+ }
49
+ const parent = dirname(dir);
50
+ if (parent === dir) break;
51
+ dir = parent;
52
+ }
53
+
54
+ throw new Error(`[prompt-router] Cannot locate assets/tools/tool-name-map.yml from ${selfDir}`);
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Harness resolution
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function resolveHarness(): Harness {
62
+ const h = process.env["NEXUS_HARNESS"];
63
+ if (h === "claude" || h === "opencode" || h === "codex") return h;
64
+ if (h) {
65
+ process.stderr.write(
66
+ `[prompt-router] Unknown NEXUS_HARNESS="${h}", falling back to "claude"\n`
67
+ );
68
+ }
69
+ return "claude";
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Invocation expansion helper
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function expand(template: string, harness: Harness): string {
77
+ return expandInvocations(template, harness, loadInvocations());
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Rule target loader
82
+ // ---------------------------------------------------------------------------
83
+
84
+ function loadValidRuleTargets(cwd: string): string[] {
85
+ const targets: string[] = [];
86
+ for (const dir of ["assets/agents", "assets/skills"]) {
87
+ const absDir = join(cwd, dir);
88
+ if (!existsSync(absDir)) continue;
89
+ for (const entry of readdirSync(absDir, { withFileTypes: true })) {
90
+ if (entry.isDirectory()) targets.push(entry.name);
91
+ }
92
+ }
93
+ return targets;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Handler
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const handler: HookHandler = async (input): Promise<NexusHookOutput | void> => {
101
+ if (input.hook_event_name !== "UserPromptSubmit") return;
102
+
103
+ const prompt = input.prompt;
104
+ const detected: Array<{ name: string; arg?: string }> = [];
105
+
106
+ // Detect all tags — use seen Set keyed on base tag name to prevent duplicates
107
+ // (e.g. plan:auto and plan share base "plan"; whichever appears first wins)
108
+ const seen = new Set<string>();
109
+ for (const { name, regex } of TAG_PATTERNS) {
110
+ const m = regex.exec(prompt);
111
+ if (!m) continue;
112
+ const base = name.split(":")[0];
113
+ if (seen.has(base)) continue;
114
+ seen.add(base);
115
+ detected.push({ name, arg: m[1] });
116
+ }
117
+
118
+ const sessionDir = join(input.cwd, ".nexus/state", input.session_id);
119
+ const planPath = join(sessionDir, "plan.json");
120
+ const tasksPath = join(sessionDir, "tasks.json");
121
+ const hasPlan = existsSync(planPath);
122
+ const hasTasks = existsSync(tasksPath);
123
+
124
+ const harness = resolveHarness();
125
+
126
+ const notices: string[] = [];
127
+ let decision: "block" | undefined;
128
+ let block_reason: string | undefined;
129
+
130
+ for (const tag of detected) {
131
+ switch (tag.name) {
132
+ case "plan":
133
+ notices.push(
134
+ `<system-notice>[plan] tag detected. ${expand('{{skill_activation skill="nx-plan"}}', harness)} for structured planning.</system-notice>`
135
+ );
136
+ break;
137
+
138
+ case "plan:auto":
139
+ notices.push(
140
+ `<system-notice>[plan:auto] tag detected. ${expand('{{skill_activation skill="nx-plan" mode="auto"}}', harness)} for structured planning.</system-notice>`
141
+ );
142
+ break;
143
+
144
+ case "run":
145
+ if (!hasTasks) {
146
+ notices.push(
147
+ `<system-notice>[run] tag detected but no tasks.json. ${expand('{{skill_activation skill="nx-plan"}}', harness)} with args "auto" first to generate tasks, then run.</system-notice>`
148
+ );
149
+ } else {
150
+ notices.push(
151
+ `<system-notice>[run] tag detected. ${expand('{{skill_activation skill="nx-run"}}', harness)} to execute tasks.</system-notice>`
152
+ );
153
+ }
154
+ break;
155
+
156
+ case "d":
157
+ if (!hasPlan) {
158
+ decision = "block";
159
+ block_reason =
160
+ `[d] tag requires an active plan session. ${expand('{{skill_activation skill="nx-plan"}}', harness)} first.`;
161
+ } else {
162
+ notices.push(
163
+ `<system-notice>[d] tag detected. Record decision via \`nx_plan_decide(issue_id, summary)\` MCP tool.</system-notice>`
164
+ );
165
+ }
166
+ break;
167
+
168
+ case "m":
169
+ notices.push(
170
+ `<system-notice>[m] tag detected. Save a memory note to \`.nexus/memory/<prefix>-<name>.md\`. Prefix: empirical-, external-, or pattern- (see architecture.md §2-1).</system-notice>`
171
+ );
172
+ break;
173
+
174
+ case "m:gc":
175
+ notices.push(
176
+ `<system-notice>[m:gc] tag detected. Review \`.nexus/memory/\` for stale or duplicate entries and consolidate.</system-notice>`
177
+ );
178
+ break;
179
+
180
+ case "rule": {
181
+ const valid = loadValidRuleTargets(input.cwd);
182
+ notices.push(
183
+ `<system-notice>[rule] tag detected. Determine target from intent. Valid targets: ${valid.join(", ")}. Update \`.nexus/rules/<target>.md\`.</system-notice>`
184
+ );
185
+ break;
186
+ }
187
+
188
+ case "rule:name": {
189
+ const valid = loadValidRuleTargets(input.cwd);
190
+ const name = tag.arg ?? "";
191
+ if (!valid.includes(name)) {
192
+ decision = "block";
193
+ block_reason = `[rule:${name}] invalid — must be one of: ${valid.join(", ")}`;
194
+ } else {
195
+ notices.push(
196
+ `<system-notice>[rule:${name}] tag detected. Update \`.nexus/rules/${name}.md\` with user's directive.</system-notice>`
197
+ );
198
+ }
199
+ break;
200
+ }
201
+
202
+ case "sync":
203
+ notices.push(
204
+ `<system-notice>[sync] tag detected. ${expand('{{skill_activation skill="nx-sync"}}', harness)} to synchronize \`.nexus/context/\`.</system-notice>`
205
+ );
206
+ break;
207
+
208
+ case "init":
209
+ notices.push(
210
+ `<system-notice>[init] tag detected. ${expand('{{skill_activation skill="nx-init"}}', harness)} for project onboarding.</system-notice>`
211
+ );
212
+ break;
213
+
214
+ case "init:reset":
215
+ notices.push(
216
+ `<system-notice>[init:reset] tag detected. ${expand('{{skill_activation skill="nx-init" mode="reset"}}', harness)} for full re-initialization.</system-notice>`
217
+ );
218
+ break;
219
+ }
220
+ }
221
+
222
+ // No tags detected + active state → emit state notice
223
+ if (detected.length === 0) {
224
+ if (hasPlan) {
225
+ try {
226
+ const plan = JSON.parse(readFileSync(planPath, "utf-8")) as {
227
+ topic?: string;
228
+ issues?: Array<{ status: string }>;
229
+ };
230
+ const pending = plan.issues?.filter((i) => i.status === "pending").length ?? 0;
231
+ notices.push(
232
+ `<system-notice>Active plan session: "${plan.topic ?? "(unknown)"}", ${pending} issues pending.</system-notice>`
233
+ );
234
+ } catch {
235
+ // Malformed plan.json — skip notice
236
+ }
237
+ } else if (hasTasks) {
238
+ try {
239
+ const tasks = JSON.parse(readFileSync(tasksPath, "utf-8")) as {
240
+ tasks?: Array<{ status: string }>;
241
+ };
242
+ const pending = tasks.tasks?.filter((t) => t.status !== "completed").length ?? 0;
243
+ if (pending > 0) {
244
+ notices.push(
245
+ `<system-notice>Active run session: ${pending} tasks remaining in tasks.json.</system-notice>`
246
+ );
247
+ }
248
+ } catch {
249
+ // Malformed tasks.json — skip notice
250
+ }
251
+ }
252
+ }
253
+
254
+ if (decision === "block") {
255
+ return { decision, block_reason };
256
+ }
257
+ if (notices.length === 0) return;
258
+ return { additional_context: notices.join("\n\n") };
259
+ };
260
+
261
+ export default handler;
@@ -0,0 +1,11 @@
1
+ name: prompt-router
2
+ description: Detect nexus tags, inject state notices and skill invocation guidance
3
+ events: [UserPromptSubmit]
4
+ matcher: "*"
5
+ timeout: 10
6
+ fallback: warn
7
+ priority: 0
8
+ requires_capabilities:
9
+ - event.user_prompt_submit
10
+ - output.additional_context.user_prompt
11
+ - output.decision_block
@@ -0,0 +1,274 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import * as crypto from "node:crypto";
6
+ import handler from "./handler.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helper
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function makeTmpDir(): string {
13
+ return fs.mkdtempSync(
14
+ path.join(os.tmpdir(), `nexus-session-init-${crypto.randomUUID()}-`)
15
+ );
16
+ }
17
+
18
+ function sessionDir(cwd: string, sid: string): string {
19
+ return path.join(cwd, ".nexus/state", sid);
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Scenario 1: New session_id (valid) → directory + files created
24
+ // ---------------------------------------------------------------------------
25
+
26
+ describe("scenario 1 — new session_id creates state files", () => {
27
+ let cwd: string;
28
+
29
+ beforeEach(() => {
30
+ cwd = makeTmpDir();
31
+ });
32
+
33
+ afterEach(() => {
34
+ fs.rmSync(cwd, { recursive: true, force: true });
35
+ });
36
+
37
+ test("creates .nexus/state/sessions/<sid>/ directory", async () => {
38
+ const sid = crypto.randomUUID();
39
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
40
+ expect(fs.existsSync(sessionDir(cwd, sid))).toBe(true);
41
+ expect(fs.statSync(sessionDir(cwd, sid)).isDirectory()).toBe(true);
42
+ });
43
+
44
+ test("creates agent-tracker.json with content '[]'", async () => {
45
+ const sid = crypto.randomUUID();
46
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
47
+ const filePath = path.join(sessionDir(cwd, sid), "agent-tracker.json");
48
+ expect(fs.existsSync(filePath)).toBe(true);
49
+ expect(fs.readFileSync(filePath, "utf8")).toBe("[]");
50
+ });
51
+
52
+ test("creates tool-log.jsonl as empty file", async () => {
53
+ const sid = crypto.randomUUID();
54
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
55
+ const filePath = path.join(sessionDir(cwd, sid), "tool-log.jsonl");
56
+ expect(fs.existsSync(filePath)).toBe(true);
57
+ expect(fs.readFileSync(filePath, "utf8")).toBe("");
58
+ });
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Scenario 2: Existing session_id re-invoked → files overwritten (reset policy)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe("scenario 2 — existing session_id re-initialises (overwrite)", () => {
66
+ let cwd: string;
67
+
68
+ beforeEach(() => {
69
+ cwd = makeTmpDir();
70
+ });
71
+
72
+ afterEach(() => {
73
+ fs.rmSync(cwd, { recursive: true, force: true });
74
+ });
75
+
76
+ test("overwrites agent-tracker.json even when it had prior content", async () => {
77
+ const sid = crypto.randomUUID();
78
+ // First call
79
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
80
+ // Simulate prior state
81
+ const trackerPath = path.join(sessionDir(cwd, sid), "agent-tracker.json");
82
+ fs.writeFileSync(trackerPath, '[{"id":"agent-1"}]');
83
+ // Second call — must reset
84
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
85
+ expect(fs.readFileSync(trackerPath, "utf8")).toBe("[]");
86
+ });
87
+
88
+ test("overwrites tool-log.jsonl even when it had prior content", async () => {
89
+ const sid = crypto.randomUUID();
90
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
91
+ const logPath = path.join(sessionDir(cwd, sid), "tool-log.jsonl");
92
+ fs.writeFileSync(logPath, '{"tool":"Bash","ts":"2026-01-01T00:00:00Z"}\n');
93
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
94
+ expect(fs.readFileSync(logPath, "utf8")).toBe("");
95
+ });
96
+ });
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Scenario 3: Path traversal — session_id="../etc/passwd"
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe("scenario 3 — path traversal is prevented", () => {
103
+ let cwd: string;
104
+
105
+ beforeEach(() => {
106
+ cwd = makeTmpDir();
107
+ });
108
+
109
+ afterEach(() => {
110
+ fs.rmSync(cwd, { recursive: true, force: true });
111
+ });
112
+
113
+ test("does not write to the real /etc/passwd (mtime unchanged)", async () => {
114
+ // Guard: confirm /etc/passwd exists so the test is meaningful
115
+ expect(fs.existsSync("/etc/passwd")).toBe(true);
116
+ const before = fs.statSync("/etc/passwd").mtimeMs;
117
+ await handler({
118
+ hook_event_name: "SessionStart",
119
+ session_id: "../etc/passwd",
120
+ cwd,
121
+ });
122
+ const after = fs.statSync("/etc/passwd").mtimeMs;
123
+ expect(after).toBe(before);
124
+ });
125
+
126
+ test("any created path is contained within cwd", async () => {
127
+ await handler({
128
+ hook_event_name: "SessionStart",
129
+ session_id: "../etc/passwd",
130
+ cwd,
131
+ });
132
+ // Walk everything created and assert it all lives inside cwd
133
+ const sessionsRoot = path.join(cwd, ".nexus/state");
134
+ if (fs.existsSync(sessionsRoot)) {
135
+ const entries = fs.readdirSync(sessionsRoot, { recursive: true, encoding: "utf8" }) as string[];
136
+ for (const entry of entries) {
137
+ const abs = path.resolve(sessionsRoot, entry);
138
+ expect(abs.startsWith(cwd)).toBe(true);
139
+ }
140
+ }
141
+ });
142
+
143
+ test("basename extraction: if something is created it is named 'passwd' inside sessions", async () => {
144
+ await handler({
145
+ hook_event_name: "SessionStart",
146
+ session_id: "../etc/passwd",
147
+ cwd,
148
+ });
149
+ // basename('../etc/passwd') === 'passwd'
150
+ // Handler must either create state/passwd (safe) or nothing.
151
+ // It must NOT create state/../etc/passwd (which resolves to .nexus/etc/passwd — still inside cwd but wrong semantic).
152
+ // The only acceptable directory name is 'passwd' directly under state.
153
+ const sessionsRoot = path.join(cwd, ".nexus/state");
154
+ if (fs.existsSync(sessionsRoot)) {
155
+ const children = fs.readdirSync(sessionsRoot);
156
+ // Each direct child must be a non-traversal name
157
+ for (const child of children) {
158
+ expect(child).not.toContain("..");
159
+ expect(child).not.toContain("/");
160
+ }
161
+ }
162
+ });
163
+
164
+ test("session_id with embedded slash is rejected or sanitised — no nested subdirs under sessions", async () => {
165
+ const sid = "foo/bar";
166
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
167
+ // If handler created something it must not be a nested foo/bar under state.
168
+ // Acceptable: nothing, or state/bar (basename). Not acceptable: state/foo/bar.
169
+ const fooInsideSessions = path.join(cwd, ".nexus/state/foo");
170
+ if (fs.existsSync(fooInsideSessions)) {
171
+ // foo exists — bar must NOT be a subdirectory of it (that would be nested traversal)
172
+ expect(fs.existsSync(path.join(fooInsideSessions, "bar"))).toBe(false);
173
+ }
174
+ });
175
+ });
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Scenario 4: plan.json, tasks.json, memory-access.jsonl are not touched
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe("scenario 4 — project-level state files are not modified", () => {
182
+ let cwd: string;
183
+
184
+ beforeEach(() => {
185
+ cwd = makeTmpDir();
186
+ // Seed pre-existing project-level state files
187
+ const stateDir = path.join(cwd, ".nexus/state");
188
+ fs.mkdirSync(stateDir, { recursive: true });
189
+ fs.writeFileSync(path.join(stateDir, "plan.json"), '{"version":1}');
190
+ fs.writeFileSync(path.join(stateDir, "tasks.json"), '{"tasks":[]}');
191
+ fs.writeFileSync(path.join(stateDir, "memory-access.jsonl"), "entry1\n");
192
+ });
193
+
194
+ afterEach(() => {
195
+ fs.rmSync(cwd, { recursive: true, force: true });
196
+ });
197
+
198
+ test("plan.json content is unchanged after handler runs", async () => {
199
+ const sid = crypto.randomUUID();
200
+ const planPath = path.join(cwd, ".nexus/state/plan.json");
201
+ const before = fs.readFileSync(planPath, "utf8");
202
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
203
+ expect(fs.readFileSync(planPath, "utf8")).toBe(before);
204
+ });
205
+
206
+ test("tasks.json content is unchanged after handler runs", async () => {
207
+ const sid = crypto.randomUUID();
208
+ const tasksPath = path.join(cwd, ".nexus/state/tasks.json");
209
+ const before = fs.readFileSync(tasksPath, "utf8");
210
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
211
+ expect(fs.readFileSync(tasksPath, "utf8")).toBe(before);
212
+ });
213
+
214
+ test("memory-access.jsonl content is unchanged after handler runs", async () => {
215
+ const sid = crypto.randomUUID();
216
+ const memPath = path.join(cwd, ".nexus/state/memory-access.jsonl");
217
+ const before = fs.readFileSync(memPath, "utf8");
218
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
219
+ expect(fs.readFileSync(memPath, "utf8")).toBe(before);
220
+ });
221
+
222
+ test("plan.json mtime is unchanged after handler runs", async () => {
223
+ const sid = crypto.randomUUID();
224
+ const planPath = path.join(cwd, ".nexus/state/plan.json");
225
+ const before = fs.statSync(planPath).mtimeMs;
226
+ await handler({ hook_event_name: "SessionStart", session_id: sid, cwd });
227
+ expect(fs.statSync(planPath).mtimeMs).toBe(before);
228
+ });
229
+ });
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Scenario 5: Return value is undefined / void
233
+ // ---------------------------------------------------------------------------
234
+
235
+ describe("scenario 5 — return value is undefined (void)", () => {
236
+ let cwd: string;
237
+
238
+ beforeEach(() => {
239
+ cwd = makeTmpDir();
240
+ });
241
+
242
+ afterEach(() => {
243
+ fs.rmSync(cwd, { recursive: true, force: true });
244
+ });
245
+
246
+ test("returns undefined for a valid SessionStart event", async () => {
247
+ const sid = crypto.randomUUID();
248
+ const result = await handler({
249
+ hook_event_name: "SessionStart",
250
+ session_id: sid,
251
+ cwd,
252
+ });
253
+ expect(result).toBeUndefined();
254
+ });
255
+
256
+ test("returns undefined when hook_event_name is not SessionStart", async () => {
257
+ const result = await handler({
258
+ hook_event_name: "UserPromptSubmit",
259
+ session_id: crypto.randomUUID(),
260
+ cwd,
261
+ prompt: "hello",
262
+ });
263
+ expect(result).toBeUndefined();
264
+ });
265
+
266
+ test("returns undefined for path traversal input (rejected)", async () => {
267
+ const result = await handler({
268
+ hook_event_name: "SessionStart",
269
+ session_id: "../etc/passwd",
270
+ cwd,
271
+ });
272
+ expect(result).toBeUndefined();
273
+ });
274
+ });
@@ -0,0 +1,30 @@
1
+ import type { HookHandler } from "../../../src/hooks/types.js";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { join, basename } from "node:path";
4
+
5
+ const handler: HookHandler = async (input) => {
6
+ if (input.hook_event_name !== "SessionStart") return;
7
+
8
+ // Sanitize session_id to prevent path traversal
9
+ const safeSid = basename(input.session_id);
10
+ if (!safeSid || safeSid.startsWith(".") || safeSid.includes("/")) {
11
+ process.stderr.write(`[session-init] invalid session_id: ${input.session_id}\n`);
12
+ return;
13
+ }
14
+
15
+ const sessionDir = join(input.cwd, ".nexus/state", safeSid);
16
+
17
+ // Ensure directory exists (idempotent)
18
+ mkdirSync(sessionDir, { recursive: true });
19
+
20
+ // Initialize per-session state files — overwrite unconditionally (resume is intentional)
21
+ writeFileSync(join(sessionDir, "agent-tracker.json"), "[]");
22
+ writeFileSync(join(sessionDir, "tool-log.jsonl"), "");
23
+
24
+ // plan.json and tasks.json are MCP responsibility — not touched here
25
+ // memory-access.jsonl is project-level — not touched here
26
+
27
+ // No additional_context returned (decided: no context injection at session start)
28
+ };
29
+
30
+ export default handler;
@@ -0,0 +1,9 @@
1
+ name: session-init
2
+ description: Initialize per-session state files at session start
3
+ events: [SessionStart]
4
+ matcher: "*"
5
+ timeout: 10
6
+ fallback: warn
7
+ priority: 0
8
+ requires_capabilities:
9
+ - event.session_start
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "./schema/lsp-servers.schema.json",
3
+ "languages": {
4
+ "typescript": {
5
+ "extensions": {
6
+ "ts": "typescript",
7
+ "tsx": "typescriptreact",
8
+ "js": "javascript",
9
+ "jsx": "javascriptreact",
10
+ "mjs": "javascript",
11
+ "cjs": "javascript",
12
+ "mts": "typescript",
13
+ "cts": "typescript"
14
+ },
15
+ "server": {
16
+ "command_chain": ["bunx", "npx"],
17
+ "args": ["typescript-language-server", "--stdio"]
18
+ },
19
+ "install_hint": "npm i -g typescript-language-server"
20
+ },
21
+ "python": {
22
+ "extensions": {
23
+ "py": "python",
24
+ "pyi": "python"
25
+ },
26
+ "server": {
27
+ "command_chain": ["bunx", "npx"],
28
+ "args": ["pyright-langserver", "--stdio"]
29
+ },
30
+ "install_hint": "npm i -g pyright"
31
+ },
32
+ "rust": {
33
+ "extensions": {
34
+ "rs": "rust"
35
+ },
36
+ "server": {
37
+ "command_chain": ["rust-analyzer"],
38
+ "search_paths": ["~/.cargo/bin/rust-analyzer"],
39
+ "args": []
40
+ },
41
+ "install_hint": "rustup component add rust-analyzer"
42
+ },
43
+ "go": {
44
+ "extensions": {
45
+ "go": "go"
46
+ },
47
+ "server": {
48
+ "command_chain": ["gopls"],
49
+ "search_paths": ["~/go/bin/gopls", "/usr/local/go/bin/gopls"],
50
+ "args": ["serve"]
51
+ },
52
+ "install_hint": "go install golang.org/x/tools/gopls@latest"
53
+ }
54
+ }
55
+ }