@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,368 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import fs from "node:fs";
3
+ import fsPromises from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import handler from "./handler.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ let tmpDir: string;
13
+ let sessionDir: string;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nexus-agent-finalize-"));
17
+ // Mimic .nexus/state/<session_id> layout expected by handler.ts
18
+ sessionDir = path.join(tmpDir, ".nexus", "state", "sess-test");
19
+ fs.mkdirSync(sessionDir, { recursive: true });
20
+ });
21
+
22
+ afterEach(async () => {
23
+ await fsPromises.rm(tmpDir, { recursive: true, force: true });
24
+ });
25
+
26
+ function trackerPath(): string {
27
+ return path.join(sessionDir, "agent-tracker.json");
28
+ }
29
+
30
+ function toolLogPath(): string {
31
+ return path.join(sessionDir, "tool-log.jsonl");
32
+ }
33
+
34
+ function tasksPath(): string {
35
+ return path.join(sessionDir, "tasks.json");
36
+ }
37
+
38
+ function writeTracker(entries: Record<string, unknown>[]): void {
39
+ fs.writeFileSync(trackerPath(), JSON.stringify(entries, null, 2));
40
+ }
41
+
42
+ function writeToolLog(lines: Record<string, unknown>[]): void {
43
+ const content = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
44
+ fs.writeFileSync(toolLogPath(), content);
45
+ }
46
+
47
+ function writeTasks(tasks: Record<string, unknown>[]): void {
48
+ fs.writeFileSync(tasksPath(), JSON.stringify({ tasks }, null, 2));
49
+ }
50
+
51
+ function readTracker(): Record<string, unknown>[] {
52
+ return JSON.parse(fs.readFileSync(trackerPath(), "utf-8")) as Record<string, unknown>[];
53
+ }
54
+
55
+ function makeInput(overrides: Record<string, unknown> = {}) {
56
+ return {
57
+ hook_event_name: "SubagentStop" as const,
58
+ session_id: "sess-test",
59
+ cwd: tmpDir,
60
+ agent_type: "engineer",
61
+ agent_id: "agent-abc",
62
+ last_assistant_message: "done",
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Scenario 1: pending task exists — additional_context returned (English + <system-notice>)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe("Scenario 1: pending task for same role returns additional_context", () => {
72
+ it("returns additional_context containing <system-notice> when pending tasks exist for the same agent_type", async () => {
73
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
74
+ writeTasks([
75
+ { id: "task-1", status: "pending", owner: { role: "engineer" } },
76
+ { id: "task-2", status: "completed", owner: { role: "engineer" } },
77
+ ]);
78
+
79
+ const result = await handler(makeInput());
80
+
81
+ expect(result).toBeDefined();
82
+ expect(typeof result?.additional_context).toBe("string");
83
+ const ctx = result!.additional_context!;
84
+
85
+ // Must contain <system-notice> wrapper tags
86
+ expect(ctx).toContain("<system-notice>");
87
+ expect(ctx).toContain("</system-notice>");
88
+
89
+ // Must mention "coordinate remaining subagent delegation"
90
+ expect(ctx).toContain("coordinate remaining subagent delegation");
91
+
92
+ // Must be in English — no Korean characters
93
+ expect(/[\u3131-\uD7A3]/.test(ctx)).toBe(false);
94
+
95
+ // Must include the pending task id but not the completed one
96
+ expect(ctx).toContain("task-1");
97
+ expect(ctx).not.toContain("task-2");
98
+ });
99
+
100
+ it("includes all pending task ids when multiple tasks are pending for the role", async () => {
101
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
102
+ writeTasks([
103
+ { id: "task-A", status: "pending", owner: { role: "engineer" } },
104
+ { id: "task-B", status: "in_progress", owner: { role: "engineer" } },
105
+ ]);
106
+
107
+ const result = await handler(makeInput());
108
+ const ctx = result?.additional_context ?? "";
109
+ expect(ctx).toContain("task-A");
110
+ expect(ctx).toContain("task-B");
111
+ });
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Scenario 2: no pending tasks — returns undefined (no additional_context)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe("Scenario 2: no pending tasks returns undefined", () => {
119
+ it("returns undefined when all tasks for the role are completed", async () => {
120
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
121
+ writeTasks([
122
+ { id: "task-1", status: "completed", owner: { role: "engineer" } },
123
+ ]);
124
+
125
+ const result = await handler(makeInput());
126
+
127
+ expect(result).toBeUndefined();
128
+ });
129
+
130
+ it("returns undefined when tasks.json has tasks with a different role only", async () => {
131
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
132
+ writeTasks([
133
+ { id: "task-x", status: "in_progress", owner: { role: "architect" } },
134
+ ]);
135
+
136
+ const result = await handler(makeInput());
137
+
138
+ expect(result).toBeUndefined();
139
+ });
140
+
141
+ it("returns undefined when tasks.json is empty", async () => {
142
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
143
+ writeTasks([]);
144
+
145
+ const result = await handler(makeInput());
146
+
147
+ expect(result).toBeUndefined();
148
+ });
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Scenario 3: tool-log.jsonl with 3 files for the same agent_id → files_touched stored
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe("Scenario 3: files_touched aggregation from tool-log.jsonl", () => {
156
+ it("stores exactly 3 unique file paths when tool-log has 3 matching entries for agent_id", async () => {
157
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
158
+ writeToolLog([
159
+ { agent_id: "agent-abc", file: "src/foo.ts" },
160
+ { agent_id: "agent-abc", file: "src/bar.ts" },
161
+ { agent_id: "agent-abc", file: "src/baz.ts" },
162
+ // entry with a different agent_id — must NOT be included
163
+ { agent_id: "agent-xyz", file: "src/other.ts" },
164
+ ]);
165
+ writeTasks([]);
166
+
167
+ await handler(makeInput());
168
+
169
+ const tracker = readTracker();
170
+ const entry = tracker.find((e) => e["agent_id"] === "agent-abc");
171
+ expect(entry).toBeDefined();
172
+ const touched = entry!["files_touched"] as string[];
173
+ expect(Array.isArray(touched)).toBe(true);
174
+ expect(touched).toHaveLength(3);
175
+ expect(touched).toContain("src/foo.ts");
176
+ expect(touched).toContain("src/bar.ts");
177
+ expect(touched).toContain("src/baz.ts");
178
+ expect(touched).not.toContain("src/other.ts");
179
+ });
180
+
181
+ it("deduplicates repeated file paths — files_touched contains only unique entries", async () => {
182
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
183
+ writeToolLog([
184
+ { agent_id: "agent-abc", file: "src/foo.ts" },
185
+ { agent_id: "agent-abc", file: "src/foo.ts" }, // duplicate
186
+ { agent_id: "agent-abc", file: "src/bar.ts" },
187
+ ]);
188
+ writeTasks([]);
189
+
190
+ await handler(makeInput());
191
+
192
+ const tracker = readTracker();
193
+ const entry = tracker.find((e) => e["agent_id"] === "agent-abc");
194
+ const touched = entry!["files_touched"] as string[];
195
+ expect(touched).toHaveLength(2);
196
+ expect(touched).toContain("src/foo.ts");
197
+ expect(touched).toContain("src/bar.ts");
198
+ });
199
+ });
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Scenario 4: entry missing (tracker empty or agent_id mismatch) → silent skip
203
+ // ---------------------------------------------------------------------------
204
+
205
+ describe("Scenario 4: missing tracker entry — silent skip (tracker not modified)", () => {
206
+ it("does not modify the tracker and does not throw when tracker array is empty", async () => {
207
+ writeTracker([]);
208
+ writeTasks([]);
209
+
210
+ const result = await handler(makeInput());
211
+
212
+ // No pending tasks → no additional_context
213
+ expect(result).toBeUndefined();
214
+ // Tracker must remain unchanged (empty)
215
+ const tracker = readTracker();
216
+ expect(tracker).toHaveLength(0);
217
+ });
218
+
219
+ it("does not modify the non-matching entry and returns undefined when agent_id is not found", async () => {
220
+ writeTracker([{ agent_id: "agent-OTHER", status: "running" }]);
221
+ writeTasks([]);
222
+
223
+ const result = await handler(makeInput());
224
+
225
+ expect(result).toBeUndefined();
226
+ // Tracker entry for unmatched agent must remain unchanged
227
+ const tracker = readTracker();
228
+ expect(tracker).toHaveLength(1);
229
+ expect(tracker[0]["status"]).toBe("running");
230
+ // No status update, stopped_at, last_message, or files_touched was added
231
+ expect(tracker[0]["stopped_at"]).toBeUndefined();
232
+ expect(tracker[0]["files_touched"]).toBeUndefined();
233
+ });
234
+
235
+ it("does not throw and returns undefined when tasks.json is absent and tracker has no matching entry", async () => {
236
+ writeTracker([{ agent_id: "agent-OTHER", status: "running" }]);
237
+ // tasks.json intentionally absent
238
+
239
+ const result = await handler(makeInput());
240
+
241
+ expect(result).toBeUndefined();
242
+ });
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Scenario 5: tracker entry updated with status="completed", stopped_at, last_message
247
+ // ---------------------------------------------------------------------------
248
+
249
+ describe("Scenario 5: tracker entry fields updated on agent stop", () => {
250
+ it("sets status to 'completed' and records stopped_at as a valid ISO timestamp", async () => {
251
+ const before = new Date();
252
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
253
+ writeTasks([]);
254
+
255
+ await handler(makeInput({ last_assistant_message: "Task finished." }));
256
+
257
+ const after = new Date();
258
+ const tracker = readTracker();
259
+ const entry = tracker.find((e) => e["agent_id"] === "agent-abc");
260
+ expect(entry).toBeDefined();
261
+
262
+ expect(entry!["status"]).toBe("completed");
263
+
264
+ const stoppedAt = entry!["stopped_at"] as string;
265
+ expect(typeof stoppedAt).toBe("string");
266
+ const stoppedDate = new Date(stoppedAt);
267
+ // Must be a valid date in the range [before, after]
268
+ expect(stoppedDate.getTime()).toBeGreaterThanOrEqual(before.getTime());
269
+ expect(stoppedDate.getTime()).toBeLessThanOrEqual(after.getTime());
270
+ });
271
+
272
+ it("records last_message in the tracker entry", async () => {
273
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
274
+ writeTasks([]);
275
+
276
+ await handler(makeInput({ last_assistant_message: "Short message." }));
277
+
278
+ const tracker = readTracker();
279
+ const entry = tracker.find((e) => e["agent_id"] === "agent-abc");
280
+ expect(entry!["last_message"]).toBe("Short message.");
281
+ });
282
+ });
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Scenario 6: last_assistant_message > 500 chars → last_message sliced to 500 chars
286
+ // ---------------------------------------------------------------------------
287
+
288
+ describe("Scenario 6: last_assistant_message truncated to 500 chars in last_message", () => {
289
+ it("stores only the first 500 characters of a message exceeding 500 chars", async () => {
290
+ const longMessage = "A".repeat(501);
291
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
292
+ writeTasks([]);
293
+
294
+ await handler(makeInput({ last_assistant_message: longMessage }));
295
+
296
+ const tracker = readTracker();
297
+ const entry = tracker.find((e) => e["agent_id"] === "agent-abc");
298
+ expect(entry).toBeDefined();
299
+
300
+ const stored = entry!["last_message"] as string;
301
+ expect(stored.length).toBe(500);
302
+ expect(stored).toBe("A".repeat(500));
303
+ });
304
+
305
+ it("stores the message unchanged when it is exactly 500 chars", async () => {
306
+ const exactMessage = "B".repeat(500);
307
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
308
+ writeTasks([]);
309
+
310
+ await handler(makeInput({ last_assistant_message: exactMessage }));
311
+
312
+ const tracker = readTracker();
313
+ const entry = tracker.find((e) => e["agent_id"] === "agent-abc");
314
+ const stored = entry!["last_message"] as string;
315
+ expect(stored.length).toBe(500);
316
+ expect(stored).toBe(exactMessage);
317
+ });
318
+
319
+ it("stores the full message unchanged when it is under 500 chars", async () => {
320
+ const shortMessage = "Hello world";
321
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
322
+ writeTasks([]);
323
+
324
+ await handler(makeInput({ last_assistant_message: shortMessage }));
325
+
326
+ const tracker = readTracker();
327
+ const entry = tracker.find((e) => e["agent_id"] === "agent-abc");
328
+ expect(entry!["last_message"]).toBe(shortMessage);
329
+ });
330
+
331
+ it("handles missing last_assistant_message as empty string (no throw, last_message is empty)", async () => {
332
+ writeTracker([{ agent_id: "agent-abc", status: "running" }]);
333
+ writeTasks([]);
334
+
335
+ const inputWithoutMessage = {
336
+ hook_event_name: "SubagentStop" as const,
337
+ session_id: "sess-test",
338
+ cwd: tmpDir,
339
+ agent_type: "engineer",
340
+ agent_id: "agent-abc",
341
+ // last_assistant_message intentionally omitted
342
+ };
343
+
344
+ await handler(inputWithoutMessage);
345
+
346
+ const tracker = readTracker();
347
+ const entry = tracker.find((e) => e["agent_id"] === "agent-abc");
348
+ expect(entry!["last_message"]).toBe("");
349
+ });
350
+ });
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Guard: non-SubagentStop events are silently ignored
354
+ // ---------------------------------------------------------------------------
355
+
356
+ describe("Guard: non-SubagentStop events", () => {
357
+ it("returns undefined immediately for non-SubagentStop events without touching any files", async () => {
358
+ const result = await handler({
359
+ hook_event_name: "SessionStart",
360
+ session_id: "sess-test",
361
+ cwd: tmpDir,
362
+ });
363
+
364
+ expect(result).toBeUndefined();
365
+ // No tracker file should have been created
366
+ expect(fs.existsSync(trackerPath())).toBe(false);
367
+ });
368
+ });
@@ -0,0 +1,76 @@
1
+ import type { HookHandler } from "../../../src/hooks/types.js";
2
+ import { updateJsonFileLocked } from "../../../src/shared/json-store.js";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const handler: HookHandler = async (input) => {
7
+ if (input.hook_event_name !== "SubagentStop") return;
8
+
9
+ const { cwd, session_id, agent_type, agent_id } = input;
10
+ const lastMessage = (input.last_assistant_message ?? "").slice(0, 500);
11
+
12
+ const sessionDir = join(cwd, ".nexus/state", session_id);
13
+ const trackerPath = join(sessionDir, "agent-tracker.json");
14
+ const toolLogPath = join(sessionDir, "tool-log.jsonl");
15
+ const tasksPath = join(sessionDir, "tasks.json");
16
+
17
+ // 1. tracker update + files_touched aggregation (locked)
18
+ await updateJsonFileLocked(trackerPath, [], (tracker: unknown[]) => {
19
+ const entry = (tracker as Record<string, unknown>[]).find(
20
+ (e) => e["agent_id"] === agent_id,
21
+ );
22
+ if (!entry) return tracker;
23
+
24
+ entry["status"] = "completed";
25
+ entry["stopped_at"] = new Date().toISOString();
26
+ entry["last_message"] = lastMessage;
27
+
28
+ if (existsSync(toolLogPath)) {
29
+ const files = new Set<string>();
30
+ const raw = readFileSync(toolLogPath, "utf-8");
31
+ for (const line of raw.split("\n")) {
32
+ if (!line.trim()) continue;
33
+ try {
34
+ const log = JSON.parse(line) as Record<string, unknown>;
35
+ if (log["agent_id"] === agent_id && typeof log["file"] === "string") {
36
+ files.add(log["file"]);
37
+ }
38
+ } catch {
39
+ // skip malformed lines
40
+ }
41
+ }
42
+ entry["files_touched"] = [...files];
43
+ }
44
+
45
+ return tracker;
46
+ });
47
+
48
+ // 2. pending tasks alert (owner.role === agent_type)
49
+ if (!existsSync(tasksPath)) return;
50
+
51
+ try {
52
+ const tasksData = JSON.parse(readFileSync(tasksPath, "utf-8")) as unknown;
53
+ const tasks: Record<string, unknown>[] = Array.isArray(
54
+ (tasksData as Record<string, unknown>)?.["tasks"],
55
+ )
56
+ ? ((tasksData as Record<string, unknown>)["tasks"] as Record<string, unknown>[])
57
+ : [];
58
+
59
+ const incomplete = tasks.filter(
60
+ (t) =>
61
+ (t["owner"] as Record<string, unknown> | undefined)?.["role"] === agent_type &&
62
+ t["status"] !== "completed",
63
+ );
64
+
65
+ if (incomplete.length === 0) return;
66
+
67
+ const ids = incomplete.map((t) => t["id"]).join(", ");
68
+ return {
69
+ additional_context: `<system-notice>\nSubagent "${agent_type}" finished. Tasks still pending with this role: ${ids}. Review status and coordinate remaining subagent delegation.\n</system-notice>`,
70
+ };
71
+ } catch {
72
+ return;
73
+ }
74
+ };
75
+
76
+ export default handler;
@@ -0,0 +1,10 @@
1
+ name: agent-finalize
2
+ description: Finalize subagent tracker, aggregate files_touched, alert pending tasks with same role
3
+ events: [SubagentStop]
4
+ matcher: "*"
5
+ timeout: 10
6
+ fallback: warn
7
+ priority: 0
8
+ requires_capabilities:
9
+ - event.subagent_stop
10
+ - output.additional_context.subagent_stop