@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,198 @@
1
+ # capability-matrix.yml
2
+ # SSOT for agent capability[] -> per-harness enforcement mapping
3
+ #
4
+ # Purpose:
5
+ # Build scripts read this file to emit harness-specific agent configuration
6
+ # from the canonical `capabilities:` array in each agent's meta.yml.
7
+ # This is NOT the hook capability matrix (that lives in assets/hooks/capability-matrix.yml);
8
+ # this file exclusively concerns agent definition denylist enforcement.
9
+ #
10
+ # Schema:
11
+ # Each top-level key under `capabilities:` is a capability ID that may appear
12
+ # in assets/agents/*/meta.yml `capabilities:` arrays.
13
+ #
14
+ # Per-harness sub-keys:
15
+ # claude:
16
+ # disallowedTools: [...] # Appended to the agent .md frontmatter disallowedTools list
17
+ # # Source: https://code.claude.com/docs/en/sub-agents
18
+ # # Verified in: .nexus/memory/external-harness-agent-permissions.md §2
19
+ #
20
+ # opencode:
21
+ # permission: {} # Merged into the agent .md frontmatter `permission:` block
22
+ # # Source: https://opencode.ai/docs/agents/
23
+ # # Verified in: .nexus/memory/external-harness-agent-permissions.md §3
24
+ #
25
+ # codex:
26
+ # sandbox_mode: ... # If set, written as sandbox_mode key in agent TOML
27
+ # # Source: https://developers.openai.com/codex/config-reference
28
+ # # Verified in: .nexus/memory/external-harness-agent-permissions.md §4
29
+ # disabled_tools: [...] # Appended to [mcp_servers.nx] disabled_tools in agent TOML
30
+ #
31
+ # MCP tool name convention:
32
+ # Claude: mcp__plugin_claude-nexus_nx__<tool>
33
+ # OpenCode: permission key uses tool name directly (server-namespaced wildcard supported)
34
+ # Codex: disabled_tools uses bare tool names as registered in [mcp_servers.nx]
35
+ # Source: .nexus/memory/external-harness-agent-permissions.md §2-4
36
+ #
37
+ # Decision basis:
38
+ # "Agent definition denylist is the single portable enforcement mechanism
39
+ # across all 3 harnesses — caller propagation is not reliable on any."
40
+ # Source: .nexus/memory/external-harness-agent-permissions.md §5-6
41
+ #
42
+ # Updated: 2026-04-19
43
+ # Maintained by: nexus-core team
44
+
45
+ capabilities:
46
+
47
+ # ── no_file_edit ────────────────────────────────────────────────────────────
48
+ # Blocks all file creation and modification by the agent.
49
+ # Applied to: HOW agents (architect, designer, postdoc, strategist),
50
+ # CHECK agents (reviewer), DO agents (researcher).
51
+ # These agents are advisory/analysis roles that must never write to disk.
52
+ #
53
+ # Claude disallowedTools:
54
+ # Edit, Write, MultiEdit, NotebookEdit cover all first-class file-write tools.
55
+ # Source: https://code.claude.com/docs/en/sub-agents (disallowedTools field)
56
+ # Verified: .nexus/memory/external-claude-code-hooks-tools.md §6 (tool catalog)
57
+ # Note: LSP-based rename/write is blocked via the Edit/Write entries.
58
+ #
59
+ # OpenCode permission:
60
+ # edit: deny blocks the `edit` and `write` built-in tools (OpenCode v1.1.1+).
61
+ # apply_patch also requires edit permission; denied transitively.
62
+ # Source: https://opencode.ai/docs/agents/ (permission field)
63
+ # Verified: .nexus/memory/external-harness-agent-permissions.md §3
64
+ #
65
+ # Codex sandbox_mode:
66
+ # "read-only" activates an OS-level sandbox that blocks all file writes.
67
+ # This is the only reliable mechanism in Codex — there are no independent
68
+ # Edit/Write tools to deny (Codex uses apply_patch, which goes through shell).
69
+ # Source: https://developers.openai.com/codex/config-reference
70
+ # Verified: .nexus/memory/external-harness-agent-permissions.md §4
71
+
72
+ no_file_edit:
73
+ claude:
74
+ disallowedTools:
75
+ - Edit
76
+ - Write
77
+ - MultiEdit
78
+ - NotebookEdit
79
+ opencode:
80
+ permission:
81
+ edit: deny
82
+ codex:
83
+ sandbox_mode: read-only
84
+ disabled_tools: [] # OS sandbox is sufficient; no tool-level entries needed
85
+
86
+ # ── no_task_create ──────────────────────────────────────────────────────────
87
+ # Blocks the agent from creating new tasks via nx_task_add.
88
+ # Applied to: all non-Lead agents (9 agents total).
89
+ # Task creation is a Lead-only operation. Soft-gating via denylist is the
90
+ # only portable enforcement; caller propagation is unreliable across harnesses.
91
+ # Source: .nexus/memory/external-harness-agent-permissions.md §5-6
92
+ #
93
+ # Claude disallowedTools:
94
+ # Full MCP tool name with plugin namespace prefix.
95
+ # Wildcard mcp__* syntax is supported in disallowedTools but not confirmed
96
+ # stable across all Claude versions; explicit name is used here for safety.
97
+ # Source: https://code.claude.com/docs/en/sub-agents
98
+ # Verified: .nexus/memory/external-harness-agent-permissions.md §2
99
+ #
100
+ # OpenCode permission:
101
+ # MCP tool names are used directly as permission keys.
102
+ # Wildcard mymcp_*: false pattern is supported per OpenCode agent permission docs.
103
+ # Source: https://opencode.ai/docs/agents/
104
+ # Verified: .nexus/memory/external-harness-agent-permissions.md §3
105
+ #
106
+ # Codex disabled_tools:
107
+ # Bare tool name as registered in [mcp_servers.nx] block of agent TOML.
108
+ # Source: https://developers.openai.com/codex/config-reference (disabled_tools key)
109
+ # Verified: .nexus/memory/external-harness-agent-permissions.md §4
110
+
111
+ no_task_create:
112
+ claude:
113
+ disallowedTools:
114
+ - mcp__plugin_claude-nexus_nx__nx_task_add
115
+ opencode:
116
+ # UNVERIFIED: OpenCode's permission block is documented for built-in tools
117
+ # (edit, bash) and wildcard patterns (mymcp_*). Using a bare MCP tool name
118
+ # as a key is plausible but unconfirmed. Consider switching to a wildcard
119
+ # (nx_*: deny) if this proves ineffective at runtime.
120
+ permission:
121
+ nx_task_add: deny
122
+ codex:
123
+ sandbox_mode: null # No sandbox needed; tool-level block is sufficient
124
+ disabled_tools:
125
+ - nx_task_add
126
+
127
+ # ── no_task_update ──────────────────────────────────────────────────────────
128
+ # Blocks the agent from updating task state via nx_task_update.
129
+ # Applied to: HOW agents (architect, designer, postdoc, strategist).
130
+ # These advisory agents must not modify task lifecycle — they advise Lead,
131
+ # who then updates tasks. DO/CHECK agents may update (engineer can update
132
+ # tasks it works on; reviewer/tester/researcher report to Lead).
133
+ # Source: .nexus/memory/external-harness-agent-permissions.md §2 (table)
134
+ #
135
+ # See no_task_create for per-harness source citations.
136
+
137
+ no_task_update:
138
+ claude:
139
+ disallowedTools:
140
+ - mcp__plugin_claude-nexus_nx__nx_task_update
141
+ opencode:
142
+ permission:
143
+ nx_task_update: deny
144
+ codex:
145
+ sandbox_mode: null
146
+ disabled_tools:
147
+ - nx_task_update
148
+
149
+ # ── model_tier ──────────────────────────────────────────────────────────────
150
+ # Maps the abstract `model_tier` field in meta.yml to concrete model slugs
151
+ # per harness. The build script substitutes these into agent definitions at
152
+ # build time.
153
+ #
154
+ # Rationale:
155
+ # Agents declare `model_tier: high | standard | low` rather than hard-coding
156
+ # model IDs so that model upgrades require only this file to change, not
157
+ # every agent definition.
158
+ #
159
+ # opencode: null means "inherit from user config" (opencode has no forced model
160
+ # override in the agent definition by default — the user or project config
161
+ # supplies the active model).
162
+ # Source: https://opencode.ai/docs/agents/ (model field is optional)
163
+ # Verified: .nexus/memory/external-opencode-plugin.md §5
164
+ #
165
+ # claude model slugs (partial-match basis — Claude Code resolves to latest in series):
166
+ # Source: https://code.claude.com/docs/en/sub-agents (model field)
167
+ # Verified: .nexus/memory/external-claude-code-hooks-tools.md §6 (Agent tool: model param)
168
+ #
169
+ # codex model slugs:
170
+ # Source: https://developers.openai.com/codex/config-reference (model key in agent TOML)
171
+ # Verified: .nexus/memory/external-harness-agent-permissions.md §4 (model field example)
172
+ # Note: gpt-5.4 / gpt-5.3-codex / gpt-5.4-mini reflect current Codex-tier naming
173
+ # as documented in config-reference. Update this section when OpenAI releases
174
+ # new model versions.
175
+
176
+ model_tier:
177
+
178
+ # high — used by: architect, designer, postdoc, strategist
179
+ # Reasoning-intensive advisory roles that require deep analysis.
180
+ high:
181
+ claude: claude-opus-4
182
+ codex: gpt-5.4 # UNVERIFIED slug — confirm against current Codex model list
183
+ opencode: null # inherits user config
184
+
185
+ # standard — used by: engineer, researcher, reviewer, tester, writer
186
+ # Execution and verification roles where throughput matters more than
187
+ # top-tier reasoning.
188
+ standard:
189
+ claude: claude-sonnet-4
190
+ codex: gpt-5.3-codex # UNVERIFIED slug — confirm against current Codex model list
191
+ opencode: null # inherits user config
192
+
193
+ # low — reserved tier; no agents currently assigned.
194
+ # Intended for lightweight utility agents (fast, cheap, simple tasks).
195
+ low:
196
+ claude: claude-haiku-4
197
+ codex: gpt-5.4-mini # UNVERIFIED slug — confirm against current Codex model list
198
+ opencode: null # inherits user config
@@ -0,0 +1,368 @@
1
+ /**
2
+ * agent-bootstrap handler tests
3
+ *
4
+ * Scenarios:
5
+ * (1) fresh + registered role (assets/agents/architect exists) → additional_context contains core index + rules
6
+ * (2) fresh + unregistered role ("general") → silent skip (no additional_context)
7
+ * (3) resume_count > 0 → skip (not fresh)
8
+ * (4) .nexus/rules/<role>.md absent → core index only
9
+ * (5) core index > 2KB → truncated to recent-modified N entries
10
+ * (6) tracker write side-effect absent (agent-tracker.json unchanged before/after)
11
+ */
12
+
13
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync, utimesSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+
18
+ import handler from "./handler.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Fixture helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Create a temp directory tree suitable for agent-bootstrap tests.
25
+ * Returns the cwd (root of the fixture).
26
+ *
27
+ * Layout:
28
+ * <root>/
29
+ * assets/agents/architect/ ← registered role
30
+ * .nexus/memory/ ← memory .md files
31
+ * .nexus/context/ ← context .md files
32
+ * .nexus/rules/architect.md ← role rule
33
+ * .nexus/state/sessions/<sid>/ ← session dir (no tracker by default)
34
+ */
35
+ function makeFixture(opts: {
36
+ withRule?: boolean;
37
+ memoryFiles?: number;
38
+ contextFiles?: number;
39
+ withTracker?: { agentId: string; resumeCount: number };
40
+ sessionId?: string;
41
+ } = {}): { cwd: string; sessionId: string; cleanup: () => void } {
42
+ const {
43
+ withRule = true,
44
+ memoryFiles = 1,
45
+ contextFiles = 1,
46
+ withTracker,
47
+ sessionId = "sess-test",
48
+ } = opts;
49
+
50
+ const cwd = mkdtempSync(join(tmpdir(), "nexus-bootstrap-"));
51
+
52
+ // Registered role directory
53
+ mkdirSync(join(cwd, "assets/agents/architect"), { recursive: true });
54
+
55
+ // Memory files
56
+ const memDir = join(cwd, ".nexus/memory");
57
+ mkdirSync(memDir, { recursive: true });
58
+ for (let i = 0; i < memoryFiles; i++) {
59
+ writeFileSync(join(memDir, `mem-${i}.md`), `# Memory file ${i}\nsome content`);
60
+ }
61
+
62
+ // Context files
63
+ const ctxDir = join(cwd, ".nexus/context");
64
+ mkdirSync(ctxDir, { recursive: true });
65
+ for (let i = 0; i < contextFiles; i++) {
66
+ writeFileSync(join(ctxDir, `ctx-${i}.md`), `# Context file ${i}\nsome content`);
67
+ }
68
+
69
+ // Rules
70
+ const rulesDir = join(cwd, ".nexus/rules");
71
+ mkdirSync(rulesDir, { recursive: true });
72
+ if (withRule) {
73
+ writeFileSync(join(rulesDir, "architect.md"), "Always think in systems.");
74
+ }
75
+
76
+ // Session dir
77
+ const sessionDir = join(cwd, ".nexus/state", sessionId);
78
+ mkdirSync(sessionDir, { recursive: true });
79
+
80
+ // Optional tracker
81
+ if (withTracker) {
82
+ writeFileSync(
83
+ join(sessionDir, "agent-tracker.json"),
84
+ JSON.stringify([
85
+ { agent_id: withTracker.agentId, resume_count: withTracker.resumeCount },
86
+ ])
87
+ );
88
+ }
89
+
90
+ return {
91
+ cwd,
92
+ sessionId,
93
+ cleanup: () => rmSync(cwd, { recursive: true, force: true }),
94
+ };
95
+ }
96
+
97
+ /** Build a SubagentStart input */
98
+ function makeInput(
99
+ cwd: string,
100
+ sessionId: string,
101
+ agentType: string,
102
+ agentId = "agent-001"
103
+ ) {
104
+ return {
105
+ hook_event_name: "SubagentStart" as const,
106
+ session_id: sessionId,
107
+ cwd,
108
+ agent_type: agentType,
109
+ agent_id: agentId,
110
+ };
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Scenario (1): fresh + registered role → additional_context with core index + rules
115
+ // ---------------------------------------------------------------------------
116
+
117
+ describe("scenario 1: fresh + registered role", () => {
118
+ let cleanup: () => void;
119
+
120
+ afterAll(() => cleanup?.());
121
+
122
+ test("additional_context contains core index and role rule", async () => {
123
+ const { cwd, sessionId, cleanup: c } = makeFixture({ withRule: true });
124
+ cleanup = c;
125
+
126
+ const result = await handler(makeInput(cwd, sessionId, "architect"));
127
+
128
+ expect(result).toBeDefined();
129
+ expect(result!.additional_context).toBeDefined();
130
+ const ctx = result!.additional_context!;
131
+
132
+ // Core index header
133
+ expect(ctx).toContain("Available memory/context:");
134
+ // At least one .md entry
135
+ expect(ctx).toMatch(/\.nexus\/(memory|context)\/.*\.md/);
136
+ // Role rule injection
137
+ expect(ctx).toContain("Custom rule for architect:");
138
+ expect(ctx).toContain("Always think in systems.");
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Scenario (2): fresh + unregistered role → silent skip
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe("scenario 2: fresh + unregistered role", () => {
147
+ let cleanup: () => void;
148
+
149
+ afterAll(() => cleanup?.());
150
+
151
+ test("returns undefined (no additional_context) for unknown role", async () => {
152
+ const { cwd, sessionId, cleanup: c } = makeFixture({ withRule: false });
153
+ cleanup = c;
154
+
155
+ const result = await handler(makeInput(cwd, sessionId, "general"));
156
+
157
+ // Silent skip: handler returns void / undefined
158
+ expect(result == null).toBe(true);
159
+ });
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Scenario (3): resume_count > 0 → skip entirely
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe("scenario 3: resume — skip on resume_count > 0", () => {
167
+ let cleanup: () => void;
168
+
169
+ afterAll(() => cleanup?.());
170
+
171
+ test("returns undefined when agent has been resumed", async () => {
172
+ const agentId = "agent-resume";
173
+ const { cwd, sessionId, cleanup: c } = makeFixture({
174
+ withTracker: { agentId, resumeCount: 1 },
175
+ });
176
+ cleanup = c;
177
+
178
+ const result = await handler(makeInput(cwd, sessionId, "architect", agentId));
179
+
180
+ expect(result == null).toBe(true);
181
+ });
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Scenario (4): no .nexus/rules/<role>.md → core index only (no rule block)
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe("scenario 4: no role rule file → core index only", () => {
189
+ let cleanup: () => void;
190
+
191
+ afterAll(() => cleanup?.());
192
+
193
+ test("additional_context has core index but no rule block", async () => {
194
+ const { cwd, sessionId, cleanup: c } = makeFixture({ withRule: false });
195
+ cleanup = c;
196
+
197
+ const result = await handler(makeInput(cwd, sessionId, "architect"));
198
+
199
+ expect(result).toBeDefined();
200
+ const ctx = result!.additional_context!;
201
+
202
+ expect(ctx).toContain("Available memory/context:");
203
+ expect(ctx).not.toContain("Custom rule for architect:");
204
+ });
205
+ });
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Scenario (5): core index > 2KB → truncated to recent N entries
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe("scenario 5: 2KB truncation of core index", () => {
212
+ let cleanup: () => void;
213
+
214
+ afterAll(() => cleanup?.());
215
+
216
+ test("core index is truncated when total size exceeds 2KB", async () => {
217
+ // Create enough files to exceed 2KB.
218
+ // Each entry line looks like:
219
+ // "- .nexus/memory/mem-XX.md: Memory file XX" (~45 chars + newline)
220
+ // 2048 / 46 ≈ 44 entries needed; create 60 to be safe.
221
+ const LOTS = 60;
222
+ const { cwd, sessionId, cleanup: c } = makeFixture({
223
+ withRule: false,
224
+ memoryFiles: LOTS,
225
+ contextFiles: 0,
226
+ });
227
+ cleanup = c;
228
+
229
+ // Spread mtimes so "recent" ordering is deterministic:
230
+ // give each file a distinct mtime (oldest → newest = index 0 → LOTS-1)
231
+ for (let i = 0; i < LOTS; i++) {
232
+ const filePath = join(cwd, ".nexus/memory", `mem-${i}.md`);
233
+ const t = new Date(Date.now() - (LOTS - i) * 10_000); // older files have smaller index
234
+ utimesSync(filePath, t, t);
235
+ }
236
+
237
+ const result = await handler(makeInput(cwd, sessionId, "architect"));
238
+
239
+ expect(result).toBeDefined();
240
+ const ctx = result!.additional_context!;
241
+ expect(ctx).toContain("Available memory/context:");
242
+
243
+ // The raw section between <system-notice> tags for the core index
244
+ const indexSection = ctx.split("</system-notice>")[0];
245
+
246
+ // Should NOT contain all 60 files — truncation must have kicked in
247
+ const entryCount = (indexSection.match(/- \.nexus\/memory\/mem-/g) ?? []).length;
248
+ expect(entryCount).toBeGreaterThan(0);
249
+ expect(entryCount).toBeLessThan(LOTS);
250
+
251
+ // The index section must not exceed 2KB (plus small wrapper overhead)
252
+ const indexBytes = new TextEncoder().encode(indexSection).length;
253
+ // Allow a generous margin for the header line and wrapping
254
+ expect(indexBytes).toBeLessThan(2048 + 200);
255
+ });
256
+ });
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Scenario (6): no tracker write side-effects
260
+ // ---------------------------------------------------------------------------
261
+
262
+ describe("scenario 6: handler produces no file write side-effects", () => {
263
+ let cleanup: () => void;
264
+
265
+ afterAll(() => cleanup?.());
266
+
267
+ test("agent-tracker.json is not created or modified by handler", async () => {
268
+ const { cwd, sessionId, cleanup: c } = makeFixture({ withTracker: undefined });
269
+ cleanup = c;
270
+
271
+ const trackerPath = join(cwd, ".nexus/state", sessionId, "agent-tracker.json");
272
+
273
+ // Tracker must not exist before the call
274
+ expect(existsSync(trackerPath)).toBe(false);
275
+
276
+ await handler(makeInput(cwd, sessionId, "architect"));
277
+
278
+ // Tracker must still not exist after the call
279
+ expect(existsSync(trackerPath)).toBe(false);
280
+ });
281
+
282
+ test("pre-existing agent-tracker.json is not modified by handler", async () => {
283
+ const agentId = "agent-side-effect";
284
+ const { cwd, sessionId, cleanup: c } = makeFixture({
285
+ withTracker: { agentId, resumeCount: 0 },
286
+ });
287
+ cleanup = c;
288
+
289
+ const trackerPath = join(cwd, ".nexus/state", sessionId, "agent-tracker.json");
290
+ const before = readFileSync(trackerPath, "utf-8");
291
+
292
+ await handler(makeInput(cwd, sessionId, "architect", agentId));
293
+
294
+ const after = readFileSync(trackerPath, "utf-8");
295
+ expect(after).toBe(before);
296
+ });
297
+ });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // 모듈 전역 상태 격리
301
+ // ---------------------------------------------------------------------------
302
+
303
+ describe("모듈 전역 상태 격리", () => {
304
+ // Test A: two different cwds with different agent sets produce independent results
305
+ test("Test A: different cwds return only their own roles", async () => {
306
+ const tmpDir1 = mkdtempSync(join(tmpdir(), "nexus-isolation-a1-"));
307
+ const tmpDir2 = mkdtempSync(join(tmpdir(), "nexus-isolation-a2-"));
308
+
309
+ try {
310
+ // tmpDir1: only "architect"
311
+ mkdirSync(join(tmpDir1, "assets/agents/architect"), { recursive: true });
312
+ mkdirSync(join(tmpDir1, ".nexus/memory"), { recursive: true });
313
+ writeFileSync(join(tmpDir1, ".nexus/memory/mem.md"), "# mem\ncontent");
314
+ mkdirSync(join(tmpDir1, ".nexus/state/sess1"), { recursive: true });
315
+
316
+ // tmpDir2: only "engineer"
317
+ mkdirSync(join(tmpDir2, "assets/agents/engineer"), { recursive: true });
318
+ mkdirSync(join(tmpDir2, ".nexus/memory"), { recursive: true });
319
+ writeFileSync(join(tmpDir2, ".nexus/memory/mem.md"), "# mem\ncontent");
320
+ mkdirSync(join(tmpDir2, ".nexus/state/sess2"), { recursive: true });
321
+
322
+ // Call with tmpDir1 as architect → should return context
323
+ const r1a = await handler(makeInput(tmpDir1, "sess1", "architect"));
324
+ expect(r1a).toBeDefined();
325
+
326
+ // Call with tmpDir1 as engineer → should be skipped (engineer not in tmpDir1)
327
+ const r1b = await handler(makeInput(tmpDir1, "sess1", "engineer"));
328
+ expect(r1b == null).toBe(true);
329
+
330
+ // Call with tmpDir2 as engineer → should return context
331
+ const r2a = await handler(makeInput(tmpDir2, "sess2", "engineer"));
332
+ expect(r2a).toBeDefined();
333
+
334
+ // Call with tmpDir2 as architect → should be skipped (architect not in tmpDir2)
335
+ const r2b = await handler(makeInput(tmpDir2, "sess2", "architect"));
336
+ expect(r2b == null).toBe(true);
337
+ } finally {
338
+ rmSync(tmpDir1, { recursive: true, force: true });
339
+ rmSync(tmpDir2, { recursive: true, force: true });
340
+ }
341
+ });
342
+
343
+ // Test B: adding a new role directory is picked up on the next call
344
+ test("Test B: newly added role is reflected in subsequent call", async () => {
345
+ const tmpDir = mkdtempSync(join(tmpdir(), "nexus-isolation-b-"));
346
+
347
+ try {
348
+ // Start with only "architect"
349
+ mkdirSync(join(tmpDir, "assets/agents/architect"), { recursive: true });
350
+ mkdirSync(join(tmpDir, ".nexus/memory"), { recursive: true });
351
+ writeFileSync(join(tmpDir, ".nexus/memory/mem.md"), "# mem\ncontent");
352
+ mkdirSync(join(tmpDir, ".nexus/state/sess"), { recursive: true });
353
+
354
+ // "foo" does not exist yet → should be skipped
355
+ const r1 = await handler(makeInput(tmpDir, "sess", "foo"));
356
+ expect(r1 == null).toBe(true);
357
+
358
+ // Add "foo" role directory
359
+ mkdirSync(join(tmpDir, "assets/agents/foo"), { recursive: true });
360
+
361
+ // Now "foo" should be recognized on the next call
362
+ const r2 = await handler(makeInput(tmpDir, "sess", "foo"));
363
+ expect(r2).toBeDefined();
364
+ } finally {
365
+ rmSync(tmpDir, { recursive: true, force: true });
366
+ }
367
+ });
368
+ });
@@ -0,0 +1,119 @@
1
+ import type { HookHandler } from "../../../src/hooks/types.js";
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const CORE_INDEX_SIZE_LIMIT = 2 * 1024; // 2KB
6
+
7
+ function loadValidRoles(cwd: string): string[] {
8
+ const agentsDir = join(cwd, "assets/agents");
9
+ const roles: string[] = [];
10
+ if (existsSync(agentsDir)) {
11
+ for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
12
+ if (entry.isDirectory()) roles.push(entry.name);
13
+ }
14
+ }
15
+ return roles;
16
+ }
17
+
18
+ function readFirstLine(path: string): string {
19
+ try {
20
+ const content = readFileSync(path, "utf-8");
21
+ const firstNonEmpty =
22
+ content.split("\n").find((l) => l.trim().length > 0) ?? "";
23
+ return firstNonEmpty.replace(/^#+\s*/, "").slice(0, 80);
24
+ } catch {
25
+ return "";
26
+ }
27
+ }
28
+
29
+ function buildCoreIndex(cwd: string): string {
30
+ const entries: Array<{ path: string; mtime: number; line: string }> = [];
31
+
32
+ for (const sub of [".nexus/memory", ".nexus/context"]) {
33
+ const absDir = join(cwd, sub);
34
+ if (!existsSync(absDir)) continue;
35
+ for (const f of readdirSync(absDir, { withFileTypes: true })) {
36
+ if (!f.isFile() || !f.name.endsWith(".md")) continue;
37
+ const full = join(absDir, f.name);
38
+ entries.push({
39
+ path: `${sub}/${f.name}`,
40
+ mtime: statSync(full).mtimeMs,
41
+ line: readFirstLine(full),
42
+ });
43
+ }
44
+ }
45
+
46
+ entries.sort((a, b) => b.mtime - a.mtime);
47
+
48
+ const lines: string[] = [];
49
+ let bytes = 0;
50
+ for (const e of entries) {
51
+ const formatted = `- ${e.path}: ${e.line}`;
52
+ if (bytes + formatted.length + 1 > CORE_INDEX_SIZE_LIMIT) break;
53
+ lines.push(formatted);
54
+ bytes += formatted.length + 1;
55
+ }
56
+
57
+ return lines.length > 0
58
+ ? "Available memory/context:\n" + lines.join("\n")
59
+ : "";
60
+ }
61
+
62
+ function getResumeCount(
63
+ cwd: string,
64
+ sessionId: string,
65
+ agentId: string
66
+ ): number {
67
+ const trackerPath = join(
68
+ cwd,
69
+ ".nexus/state",
70
+ sessionId,
71
+ "agent-tracker.json"
72
+ );
73
+ if (!existsSync(trackerPath)) return 0;
74
+ try {
75
+ const tracker = JSON.parse(readFileSync(trackerPath, "utf-8"));
76
+ const entry = Array.isArray(tracker)
77
+ ? tracker.find((e: { agent_id?: string }) => e.agent_id === agentId)
78
+ : null;
79
+ return (entry as { resume_count?: number } | null)?.resume_count ?? 0;
80
+ } catch {
81
+ return 0;
82
+ }
83
+ }
84
+
85
+ const handler: HookHandler = async (input) => {
86
+ if (input.hook_event_name !== "SubagentStart") return;
87
+
88
+ const { cwd, session_id, agent_type, agent_id } = input;
89
+
90
+ // fresh only — skip on resume
91
+ const resumeCount = getResumeCount(cwd, session_id, agent_id);
92
+ if (resumeCount > 0) return;
93
+
94
+ // unregistered role: silent skip
95
+ const validRoles = loadValidRoles(cwd);
96
+ if (!validRoles.includes(agent_type)) return;
97
+
98
+ const parts: string[] = [];
99
+
100
+ const coreIndex = buildCoreIndex(cwd);
101
+ if (coreIndex) {
102
+ parts.push(`<system-notice>\n${coreIndex}\n</system-notice>`);
103
+ }
104
+
105
+ const rulePath = join(cwd, ".nexus/rules", `${agent_type}.md`);
106
+ if (existsSync(rulePath)) {
107
+ const ruleContent = readFileSync(rulePath, "utf-8").trim();
108
+ if (ruleContent) {
109
+ parts.push(
110
+ `<system-notice>\nCustom rule for ${agent_type}:\n${ruleContent}\n</system-notice>`
111
+ );
112
+ }
113
+ }
114
+
115
+ if (parts.length === 0) return;
116
+ return { additional_context: parts.join("\n\n") };
117
+ };
118
+
119
+ export default handler;
@@ -0,0 +1,10 @@
1
+ name: agent-bootstrap
2
+ description: Inject core memory index and role-specific rules on fresh subagent spawn
3
+ events: [SubagentStart]
4
+ matcher: "*"
5
+ timeout: 10
6
+ fallback: warn
7
+ priority: 0
8
+ requires_capabilities:
9
+ - event.subagent_start
10
+ - output.additional_context.session_start