patchwarden 0.6.4 → 1.5.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 (254) hide show
  1. package/README.en.md +54 -20
  2. package/README.md +48 -21
  3. package/dist/agents/agentRouter.d.ts +40 -0
  4. package/dist/agents/agentRouter.js +95 -0
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.js +6 -2
  7. package/dist/controlCenter.js +212 -3
  8. package/dist/direct/directSessionStore.d.ts +2 -0
  9. package/dist/direct/directVerification.js +7 -0
  10. package/dist/doctor.js +32 -1
  11. package/dist/goal/acceptanceEngine.d.ts +62 -0
  12. package/dist/goal/acceptanceEngine.js +103 -0
  13. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  14. package/dist/goal/acceptanceTemplate.js +104 -0
  15. package/dist/goal/goalGraph.d.ts +58 -0
  16. package/dist/goal/goalGraph.js +189 -0
  17. package/dist/goal/goalProgress.d.ts +81 -0
  18. package/dist/goal/goalProgress.js +216 -0
  19. package/dist/goal/goalStatus.d.ts +60 -0
  20. package/dist/goal/goalStatus.js +137 -0
  21. package/dist/goal/goalStore.d.ts +79 -0
  22. package/dist/goal/goalStore.js +211 -0
  23. package/dist/goal/handoffExport.d.ts +34 -0
  24. package/dist/goal/handoffExport.js +183 -0
  25. package/dist/goal/subgoalSync.d.ts +40 -0
  26. package/dist/goal/subgoalSync.js +72 -0
  27. package/dist/goal/worktreeManager.d.ts +96 -0
  28. package/dist/goal/worktreeManager.js +292 -0
  29. package/dist/logging.d.ts +44 -0
  30. package/dist/logging.js +68 -0
  31. package/dist/policy/projectPolicy.d.ts +55 -0
  32. package/dist/policy/projectPolicy.js +286 -0
  33. package/dist/release/releaseGate.d.ts +99 -0
  34. package/dist/release/releaseGate.js +475 -0
  35. package/dist/runner/postTaskCleanup.d.ts +13 -0
  36. package/dist/runner/postTaskCleanup.js +147 -0
  37. package/dist/runner/runTask.js +50 -10
  38. package/dist/security/discoveryTokenStore.d.ts +62 -0
  39. package/dist/security/discoveryTokenStore.js +100 -0
  40. package/dist/security/toolInvocationGuard.d.ts +35 -0
  41. package/dist/security/toolInvocationGuard.js +153 -0
  42. package/dist/smoke-test.js +18 -10
  43. package/dist/taskRuntime.d.ts +17 -0
  44. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  45. package/dist/test/unit/acceptance-engine.test.js +228 -0
  46. package/dist/test/unit/agent-router.test.d.ts +1 -0
  47. package/dist/test/unit/agent-router.test.js +287 -0
  48. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  49. package/dist/test/unit/audit-checks.test.js +350 -0
  50. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  51. package/dist/test/unit/diagnose-task.test.js +457 -0
  52. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  53. package/dist/test/unit/discovery-token-store.test.js +139 -0
  54. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  55. package/dist/test/unit/evidence-pack.test.js +130 -0
  56. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  57. package/dist/test/unit/goal-graph.test.js +298 -0
  58. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  59. package/dist/test/unit/goal-progress.test.js +381 -0
  60. package/dist/test/unit/goal-status.test.d.ts +1 -0
  61. package/dist/test/unit/goal-status.test.js +215 -0
  62. package/dist/test/unit/goal-store.test.d.ts +1 -0
  63. package/dist/test/unit/goal-store.test.js +253 -0
  64. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  65. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  66. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  67. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  68. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  69. package/dist/test/unit/handoff-export.test.js +263 -0
  70. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  72. package/dist/test/unit/logging.test.js +127 -5
  73. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  74. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  75. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  76. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  77. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  78. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  79. package/dist/test/unit/release-gate.test.d.ts +1 -0
  80. package/dist/test/unit/release-gate.test.js +242 -0
  81. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  82. package/dist/test/unit/run-task-loop.test.js +380 -0
  83. package/dist/test/unit/safe-views.test.d.ts +1 -0
  84. package/dist/test/unit/safe-views.test.js +171 -0
  85. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  86. package/dist/test/unit/schema-drift-check.test.js +176 -0
  87. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  88. package/dist/test/unit/subgoal-sync.test.js +183 -0
  89. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  90. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  91. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  92. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  93. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  94. package/dist/test/unit/toolSearch.test.js +571 -0
  95. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  96. package/dist/test/unit/worktree-manager.test.js +176 -0
  97. package/dist/tools/auditTask.d.ts +103 -1
  98. package/dist/tools/auditTask.js +461 -8
  99. package/dist/tools/cancelTask.js +1 -1
  100. package/dist/tools/checkReleaseGate.d.ts +21 -0
  101. package/dist/tools/checkReleaseGate.js +22 -0
  102. package/dist/tools/createTask.d.ts +18 -2
  103. package/dist/tools/createTask.js +29 -1
  104. package/dist/tools/diagnoseTask.d.ts +45 -0
  105. package/dist/tools/diagnoseTask.js +347 -0
  106. package/dist/tools/discardWorktree.d.ts +24 -0
  107. package/dist/tools/discardWorktree.js +27 -0
  108. package/dist/tools/discoverTools.d.ts +11 -0
  109. package/dist/tools/discoverTools.js +39 -0
  110. package/dist/tools/evidencePack.d.ts +39 -0
  111. package/dist/tools/evidencePack.js +168 -0
  112. package/dist/tools/explainTool.d.ts +11 -0
  113. package/dist/tools/explainTool.js +21 -0
  114. package/dist/tools/getTaskSummary.js +2 -1
  115. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  116. package/dist/tools/goalSubgoalTask.js +110 -0
  117. package/dist/tools/healthCheck.d.ts +1 -0
  118. package/dist/tools/healthCheck.js +5 -1
  119. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  120. package/dist/tools/invokeDiscoveredTool.js +112 -0
  121. package/dist/tools/listTasks.d.ts +3 -1
  122. package/dist/tools/listTasks.js +14 -1
  123. package/dist/tools/mergeWorktree.d.ts +24 -0
  124. package/dist/tools/mergeWorktree.js +27 -0
  125. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  126. package/dist/tools/recommendAgentForTask.js +56 -0
  127. package/dist/tools/reconcileTasks.d.ts +41 -0
  128. package/dist/tools/reconcileTasks.js +352 -0
  129. package/dist/tools/registry.js +926 -2
  130. package/dist/tools/releaseMode.d.ts +50 -0
  131. package/dist/tools/releaseMode.js +370 -0
  132. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  133. package/dist/tools/runDirectVerificationBundle.js +64 -0
  134. package/dist/tools/runTaskLoop.d.ts +57 -0
  135. package/dist/tools/runTaskLoop.js +417 -0
  136. package/dist/tools/runVerification.d.ts +4 -0
  137. package/dist/tools/runVerification.js +4 -0
  138. package/dist/tools/safeStatus.d.ts +31 -1
  139. package/dist/tools/safeStatus.js +43 -1
  140. package/dist/tools/safeViews.d.ts +262 -0
  141. package/dist/tools/safeViews.js +252 -0
  142. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  143. package/dist/tools/schemaDriftCheck.js +80 -0
  144. package/dist/tools/taskLineage.d.ts +91 -0
  145. package/dist/tools/taskLineage.js +175 -0
  146. package/dist/tools/toolCatalog.d.ts +4 -3
  147. package/dist/tools/toolCatalog.js +39 -11
  148. package/dist/tools/toolRegistry.d.ts +61 -0
  149. package/dist/tools/toolRegistry.js +834 -0
  150. package/dist/tools/toolSearch.d.ts +110 -0
  151. package/dist/tools/toolSearch.js +331 -0
  152. package/dist/tools/toolUsageStats.d.ts +41 -0
  153. package/dist/tools/toolUsageStats.js +116 -0
  154. package/dist/tools/waitForTask.js +1 -0
  155. package/dist/version.d.ts +2 -2
  156. package/dist/version.js +2 -2
  157. package/dist/watcherStatus.d.ts +29 -0
  158. package/dist/watcherStatus.js +92 -1
  159. package/docs/chatgpt-usage.md +31 -0
  160. package/docs/control-center/README.md +9 -0
  161. package/package.json +3 -2
  162. package/scripts/checks/control-center-smoke.js +87 -0
  163. package/scripts/checks/control-smoke.js +2 -2
  164. package/scripts/checks/doctor-smoke.js +1 -0
  165. package/scripts/checks/lifecycle-smoke.js +13 -9
  166. package/scripts/checks/mcp-manifest-check.js +65 -6
  167. package/scripts/checks/mcp-smoke.js +60 -9
  168. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  169. package/src/agents/agentRouter.ts +149 -0
  170. package/src/config.ts +9 -3
  171. package/src/controlCenter.ts +215 -4
  172. package/src/direct/directSessionStore.ts +2 -0
  173. package/src/direct/directVerification.ts +7 -0
  174. package/src/doctor.ts +36 -1
  175. package/src/goal/acceptanceEngine.ts +160 -0
  176. package/src/goal/acceptanceTemplate.ts +121 -0
  177. package/src/goal/goalGraph.ts +225 -0
  178. package/src/goal/goalProgress.ts +301 -0
  179. package/src/goal/goalStatus.ts +234 -0
  180. package/src/goal/goalStore.ts +306 -0
  181. package/src/goal/handoffExport.ts +211 -0
  182. package/src/goal/subgoalSync.ts +82 -0
  183. package/src/goal/worktreeManager.ts +404 -0
  184. package/src/logging.ts +91 -0
  185. package/src/policy/projectPolicy.ts +344 -0
  186. package/src/release/releaseGate.ts +567 -0
  187. package/src/runner/postTaskCleanup.ts +154 -0
  188. package/src/runner/runTask.ts +49 -10
  189. package/src/security/discoveryTokenStore.ts +157 -0
  190. package/src/security/toolInvocationGuard.ts +251 -0
  191. package/src/smoke-test.ts +15 -7
  192. package/src/taskRuntime.ts +17 -0
  193. package/src/test/unit/acceptance-engine.test.ts +261 -0
  194. package/src/test/unit/agent-router.test.ts +342 -0
  195. package/src/test/unit/audit-checks.test.ts +567 -0
  196. package/src/test/unit/diagnose-task.test.ts +544 -0
  197. package/src/test/unit/discovery-token-store.test.ts +181 -0
  198. package/src/test/unit/evidence-pack.test.ts +142 -0
  199. package/src/test/unit/goal-graph.test.ts +347 -0
  200. package/src/test/unit/goal-progress.test.ts +538 -0
  201. package/src/test/unit/goal-status.test.ts +270 -0
  202. package/src/test/unit/goal-store.test.ts +318 -0
  203. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  204. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  205. package/src/test/unit/handoff-export.test.ts +295 -0
  206. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  207. package/src/test/unit/logging.test.ts +177 -5
  208. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  209. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  210. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  211. package/src/test/unit/release-gate.test.ts +314 -0
  212. package/src/test/unit/run-task-loop.test.ts +425 -0
  213. package/src/test/unit/safe-views.test.ts +184 -0
  214. package/src/test/unit/schema-drift-check.test.ts +260 -0
  215. package/src/test/unit/subgoal-sync.test.ts +236 -0
  216. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  217. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  218. package/src/test/unit/toolSearch.test.ts +756 -0
  219. package/src/test/unit/worktree-manager.test.ts +247 -0
  220. package/src/tools/auditTask.ts +831 -402
  221. package/src/tools/cancelTask.ts +1 -1
  222. package/src/tools/checkReleaseGate.ts +45 -0
  223. package/src/tools/createTask.ts +64 -6
  224. package/src/tools/diagnoseTask.ts +460 -0
  225. package/src/tools/discardWorktree.ts +42 -0
  226. package/src/tools/discoverTools.ts +51 -0
  227. package/src/tools/evidencePack.ts +205 -0
  228. package/src/tools/explainTool.ts +26 -0
  229. package/src/tools/getTaskSummary.ts +2 -1
  230. package/src/tools/goalSubgoalTask.ts +170 -0
  231. package/src/tools/healthCheck.ts +4 -1
  232. package/src/tools/invokeDiscoveredTool.ts +163 -0
  233. package/src/tools/listTasks.ts +16 -2
  234. package/src/tools/listWorkspace.ts +71 -71
  235. package/src/tools/mergeWorktree.ts +42 -0
  236. package/src/tools/recommendAgentForTask.ts +79 -0
  237. package/src/tools/reconcileTasks.ts +464 -0
  238. package/src/tools/registry.ts +1093 -73
  239. package/src/tools/releaseMode.ts +450 -0
  240. package/src/tools/runDirectVerificationBundle.ts +98 -0
  241. package/src/tools/runTaskLoop.ts +526 -0
  242. package/src/tools/runVerification.ts +8 -0
  243. package/src/tools/safeStatus.ts +73 -2
  244. package/src/tools/safeViews.ts +273 -0
  245. package/src/tools/schemaDriftCheck.ts +120 -0
  246. package/src/tools/taskLineage.ts +300 -0
  247. package/src/tools/toolCatalog.ts +40 -11
  248. package/src/tools/toolRegistry.ts +896 -0
  249. package/src/tools/toolSearch.ts +464 -0
  250. package/src/tools/toolUsageStats.ts +151 -0
  251. package/src/tools/waitForTask.ts +1 -0
  252. package/src/version.ts +2 -2
  253. package/src/watcherStatus.ts +97 -1
  254. package/ui/pages/dashboard.html +143 -2
@@ -0,0 +1,243 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { getToolDefs, handleToolCall } from "../../tools/registry.js";
7
+ import { reloadConfig } from "../../config.js";
8
+ import { PatchWardenError } from "../../errors.js";
9
+
10
+ // ── 9 个 Goal Session MCP 工具名 ──────────────────────────────────
11
+
12
+ const GOAL_TOOL_NAMES = [
13
+ "create_goal",
14
+ "list_goals",
15
+ "read_goal",
16
+ "create_subgoal_task",
17
+ "accept_subgoal",
18
+ "reject_subgoal",
19
+ "suggest_next_subgoal",
20
+ "summarize_goal_progress",
21
+ "export_handoff",
22
+ ] as const;
23
+
24
+ // ── 测试环境隔离:使用临时 workspaceRoot + full profile ───────────
25
+
26
+ let tempDir: string;
27
+ let prevConfigEnv: string | undefined;
28
+ let prevProfileEnv: string | undefined;
29
+
30
+ beforeEach(() => {
31
+ tempDir = mkdtempSync(join(tmpdir(), "pw-goal-registry-"));
32
+ const configPath = join(tempDir, "patchwarden.config.json");
33
+ writeFileSync(
34
+ configPath,
35
+ JSON.stringify({
36
+ workspaceRoot: tempDir,
37
+ toolProfile: "full",
38
+ agents: {},
39
+ allowedTestCommands: [],
40
+ }),
41
+ "utf-8"
42
+ );
43
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
44
+ prevProfileEnv = process.env.PATCHWARDEN_TOOL_PROFILE;
45
+ process.env.PATCHWARDEN_CONFIG = configPath;
46
+ process.env.PATCHWARDEN_TOOL_PROFILE = "full";
47
+ reloadConfig();
48
+ });
49
+
50
+ afterEach(() => {
51
+ if (prevConfigEnv === undefined) {
52
+ delete process.env.PATCHWARDEN_CONFIG;
53
+ } else {
54
+ process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
55
+ }
56
+ if (prevProfileEnv === undefined) {
57
+ delete process.env.PATCHWARDEN_TOOL_PROFILE;
58
+ } else {
59
+ process.env.PATCHWARDEN_TOOL_PROFILE = prevProfileEnv;
60
+ }
61
+ reloadConfig();
62
+ rmSync(tempDir, { recursive: true, force: true });
63
+ });
64
+
65
+ // ── Tests ─────────────────────────────────────────────────────────
66
+
67
+ describe("goal tools registry", () => {
68
+
69
+ describe("getToolDefs — 工具定义", () => {
70
+ it("包含全部 9 个 goal 工具", () => {
71
+ const tools = getToolDefs();
72
+ const names = new Set(tools.map((t) => t.name));
73
+ for (const name of GOAL_TOOL_NAMES) {
74
+ assert.ok(names.has(name), `Missing tool definition: ${name}`);
75
+ }
76
+ });
77
+
78
+ it("每个 goal 工具描述以 v0.8.0 开头且 inputSchema.type 为 object", () => {
79
+ const tools = getToolDefs();
80
+ const byName = new Map(tools.map((t) => [t.name, t]));
81
+ for (const name of GOAL_TOOL_NAMES) {
82
+ const tool = byName.get(name);
83
+ assert.ok(tool, `Tool not found: ${name}`);
84
+ assert.equal(tool.inputSchema.type, "object", `${name} schema type should be object`);
85
+ assert.ok(
86
+ tool.description.startsWith("v0.8.0:"),
87
+ `${name} description should start with v0.8.0:`
88
+ );
89
+ }
90
+ });
91
+
92
+ it("create_goal required 字段为 title/goal_description/repo_path", () => {
93
+ const tools = getToolDefs();
94
+ const tool = tools.find((t) => t.name === "create_goal");
95
+ assert.ok(tool);
96
+ assert.deepEqual(tool.inputSchema.required, ["title", "goal_description", "repo_path"]);
97
+ });
98
+
99
+ it("create_subgoal_task required 字段为 goal_id/subgoal_title/repo_path", () => {
100
+ const tools = getToolDefs();
101
+ const tool = tools.find((t) => t.name === "create_subgoal_task");
102
+ assert.ok(tool);
103
+ assert.deepEqual(tool.inputSchema.required, ["goal_id", "subgoal_title", "repo_path"]);
104
+ });
105
+
106
+ it("create_subgoal_task template enum 复用 TASK_TEMPLATE_NAMES", () => {
107
+ const tools = getToolDefs();
108
+ const tool = tools.find((t) => t.name === "create_subgoal_task");
109
+ assert.ok(tool);
110
+ const props = tool.inputSchema.properties as Record<string, any>;
111
+ assert.ok(Array.isArray(props.template.enum));
112
+ assert.ok(props.template.enum.length > 0);
113
+ });
114
+
115
+ it("accept_subgoal required 字段为 goal_id/subgoal_id", () => {
116
+ const tools = getToolDefs();
117
+ const tool = tools.find((t) => t.name === "accept_subgoal");
118
+ assert.ok(tool);
119
+ assert.deepEqual(tool.inputSchema.required, ["goal_id", "subgoal_id"]);
120
+ });
121
+
122
+ it("reject_subgoal required 字段为 goal_id/subgoal_id/reason", () => {
123
+ const tools = getToolDefs();
124
+ const tool = tools.find((t) => t.name === "reject_subgoal");
125
+ assert.ok(tool);
126
+ assert.deepEqual(tool.inputSchema.required, ["goal_id", "subgoal_id", "reason"]);
127
+ });
128
+ });
129
+
130
+ describe("handleToolCall — handler 分发", () => {
131
+ it("list_goals 返回 { goals: [] } 格式(空工作区)", async () => {
132
+ const result = await handleToolCall("list_goals", {});
133
+ assert.ok(Array.isArray(result.content));
134
+ assert.equal(result.content[0].type, "text");
135
+ const data = JSON.parse(result.content[0].text);
136
+ assert.ok(Array.isArray(data.goals));
137
+ assert.equal(data.goals.length, 0);
138
+ });
139
+
140
+ it("read_goal 不存在时抛出 goal_not_found", async () => {
141
+ await assert.rejects(
142
+ () => handleToolCall("read_goal", { goal_id: "goal_nonexistent_xyz_99999" }),
143
+ (err: unknown) => {
144
+ assert.ok(err instanceof PatchWardenError, "should be PatchWardenError");
145
+ assert.equal(err.reason, "goal_not_found");
146
+ return true;
147
+ }
148
+ );
149
+ });
150
+
151
+ it("create_goal 路径越界抛出 workspace_path_escape", async () => {
152
+ await assert.rejects(
153
+ () => handleToolCall("create_goal", {
154
+ title: "Test Goal",
155
+ goal_description: "desc",
156
+ repo_path: "../../../../..",
157
+ }),
158
+ (err: unknown) => {
159
+ assert.ok(err instanceof PatchWardenError, "should be PatchWardenError");
160
+ assert.equal(err.reason, "workspace_path_escape");
161
+ return true;
162
+ }
163
+ );
164
+ });
165
+
166
+ it("suggest_next_subgoal 不存在时抛出 goal_not_found", async () => {
167
+ await assert.rejects(
168
+ () => handleToolCall("suggest_next_subgoal", { goal_id: "goal_nonexistent_xyz_99999" }),
169
+ (err: unknown) => {
170
+ assert.ok(err instanceof PatchWardenError, "should be PatchWardenError");
171
+ assert.equal(err.reason, "goal_not_found");
172
+ return true;
173
+ }
174
+ );
175
+ });
176
+
177
+ it("summarize_goal_progress 不存在时抛出 goal_not_found", async () => {
178
+ await assert.rejects(
179
+ () => handleToolCall("summarize_goal_progress", { goal_id: "goal_nonexistent_xyz_99999" }),
180
+ (err: unknown) => {
181
+ assert.ok(err instanceof PatchWardenError, "should be PatchWardenError");
182
+ assert.equal(err.reason, "goal_not_found");
183
+ return true;
184
+ }
185
+ );
186
+ });
187
+
188
+ it("export_handoff 不存在时抛出 goal_not_found", async () => {
189
+ await assert.rejects(
190
+ () => handleToolCall("export_handoff", { goal_id: "goal_nonexistent_xyz_99999" }),
191
+ (err: unknown) => {
192
+ assert.ok(err instanceof PatchWardenError, "should be PatchWardenError");
193
+ assert.equal(err.reason, "goal_not_found");
194
+ return true;
195
+ }
196
+ );
197
+ });
198
+
199
+ it("accept_subgoal 不存在时抛出 goal_not_found", async () => {
200
+ await assert.rejects(
201
+ () => handleToolCall("accept_subgoal", {
202
+ goal_id: "goal_nonexistent_xyz_99999",
203
+ subgoal_id: "sg_1",
204
+ }),
205
+ (err: unknown) => {
206
+ assert.ok(err instanceof PatchWardenError, "should be PatchWardenError");
207
+ assert.equal(err.reason, "goal_not_found");
208
+ return true;
209
+ }
210
+ );
211
+ });
212
+
213
+ it("reject_subgoal 不存在时抛出 goal_not_found", async () => {
214
+ await assert.rejects(
215
+ () => handleToolCall("reject_subgoal", {
216
+ goal_id: "goal_nonexistent_xyz_99999",
217
+ subgoal_id: "sg_1",
218
+ reason: "test reason",
219
+ }),
220
+ (err: unknown) => {
221
+ assert.ok(err instanceof PatchWardenError, "should be PatchWardenError");
222
+ assert.equal(err.reason, "goal_not_found");
223
+ return true;
224
+ }
225
+ );
226
+ });
227
+
228
+ it("create_subgoal_task 不存在时抛出 goal_not_found", async () => {
229
+ await assert.rejects(
230
+ () => handleToolCall("create_subgoal_task", {
231
+ goal_id: "goal_nonexistent_xyz_99999",
232
+ subgoal_title: "Sub A",
233
+ repo_path: "repo",
234
+ }),
235
+ (err: unknown) => {
236
+ assert.ok(err instanceof PatchWardenError, "should be PatchWardenError");
237
+ assert.equal(err.reason, "goal_not_found");
238
+ return true;
239
+ }
240
+ );
241
+ });
242
+ });
243
+ });
@@ -0,0 +1,295 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, existsSync, readFileSync, statSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { generateHandoff, exportHandoff } from "../../goal/handoffExport.js";
7
+ import type { GoalStatus, Subgoal } from "../../goal/goalStatus.js";
8
+
9
+ // ── Helpers ───────────────────────────────────────────────────────
10
+
11
+ function makeSubgoal(overrides: Partial<Subgoal> & { id: string }): Subgoal {
12
+ return {
13
+ title: "Subgoal " + overrides.id,
14
+ status: "ready",
15
+ depends_on: [],
16
+ task_ids: [],
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ function makeGoalStatus(subgoals: Subgoal[], overrides: Partial<GoalStatus> = {}): GoalStatus {
22
+ const now = new Date().toISOString();
23
+ return {
24
+ goal_id: "goal_test_001",
25
+ title: "Test Goal",
26
+ status: "active",
27
+ repo_path: "/repo/test",
28
+ created_at: now,
29
+ updated_at: now,
30
+ subgoals,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ // ── Tests ─────────────────────────────────────────────────────────
36
+
37
+ describe("handoffExport", () => {
38
+
39
+ describe("generateHandoff", () => {
40
+
41
+ it("空 subgoals → 文档包含所有章节", () => {
42
+ const goal = makeGoalStatus([]);
43
+ const md = generateHandoff("goal_test_001", goal);
44
+ assert.ok(md.includes("# Goal Session Handoff"));
45
+ assert.ok(md.includes("## 当前 Goal"));
46
+ assert.ok(md.includes("## 版本目标"));
47
+ assert.ok(md.includes("## 已完成子目标"));
48
+ assert.ok(md.includes("## 未完成子目标"));
49
+ assert.ok(md.includes("## 已拒绝子目标"));
50
+ assert.ok(md.includes("## 最近一次 diff 摘要"));
51
+ assert.ok(md.includes("## 最近一次测试结果"));
52
+ assert.ok(md.includes("## 当前阻塞点"));
53
+ assert.ok(md.includes("## 下一步建议"));
54
+ assert.ok(md.includes("## 风险提醒"));
55
+ assert.ok(md.includes("## 接手说明"));
56
+ assert.ok(md.includes("Generated:"));
57
+ });
58
+
59
+ it("空 subgoals → 已完成/未完成显示无", () => {
60
+ const goal = makeGoalStatus([]);
61
+ const md = generateHandoff("goal_test_001", goal);
62
+ const completedSection = md.split("## 已完成子目标")[1].split("##")[0];
63
+ assert.ok(completedSection.includes("无"));
64
+ const incompleteSection = md.split("## 未完成子目标")[1].split("##")[0];
65
+ assert.ok(incompleteSection.includes("无"));
66
+ });
67
+
68
+ it("有 accepted subgoal → 出现在已完成子目标", () => {
69
+ const goal = makeGoalStatus([
70
+ makeSubgoal({
71
+ id: "subgoal-001",
72
+ title: "Implement feature A",
73
+ status: "accepted",
74
+ accepted_at: "2026-01-01T00:00:00.000Z",
75
+ }),
76
+ ]);
77
+ const md = generateHandoff("goal_test_001", goal);
78
+ const completedSection = md.split("## 已完成子目标")[1].split("##")[0];
79
+ assert.ok(completedSection.includes("subgoal-001"));
80
+ assert.ok(completedSection.includes("Implement feature A"));
81
+ assert.ok(completedSection.includes("2026-01-01T00:00:00.000Z"));
82
+ });
83
+
84
+ it("有 running subgoal → 出现在未完成子目标和风险提醒", () => {
85
+ const goal = makeGoalStatus([
86
+ makeSubgoal({
87
+ id: "subgoal-001",
88
+ title: "WIP task",
89
+ status: "running",
90
+ }),
91
+ ]);
92
+ const md = generateHandoff("goal_test_001", goal);
93
+ const incompleteSection = md.split("## 未完成子目标")[1].split("##")[0];
94
+ assert.ok(incompleteSection.includes("subgoal-001"));
95
+ assert.ok(incompleteSection.includes("running"));
96
+ const riskSection = md.split("## 风险提醒")[1].split("##")[0];
97
+ assert.ok(riskSection.includes("subgoal-001"));
98
+ assert.ok(riskSection.includes("running"));
99
+ });
100
+
101
+ it("有 rejected subgoal → 出现在已拒绝子目标", () => {
102
+ const goal = makeGoalStatus([
103
+ makeSubgoal({
104
+ id: "subgoal-001",
105
+ title: "Failed task",
106
+ status: "rejected",
107
+ rejected_reason: "Tests failed",
108
+ }),
109
+ ]);
110
+ const md = generateHandoff("goal_test_001", goal);
111
+ const rejectedSection = md.split("## 已拒绝子目标")[1].split("##")[0];
112
+ assert.ok(rejectedSection.includes("subgoal-001"));
113
+ assert.ok(rejectedSection.includes("Failed task"));
114
+ assert.ok(rejectedSection.includes("Tests failed"));
115
+ });
116
+
117
+ it("无 rejected subgoal → 已拒绝子目标显示无", () => {
118
+ const goal = makeGoalStatus([]);
119
+ const md = generateHandoff("goal_test_001", goal);
120
+ const rejectedSection = md.split("## 已拒绝子目标")[1].split("##")[0];
121
+ assert.ok(rejectedSection.includes("无"));
122
+ });
123
+
124
+ it("有 needs_fix subgoal → 出现在当前阻塞点和风险提醒", () => {
125
+ const goal = makeGoalStatus([
126
+ makeSubgoal({
127
+ id: "subgoal-001",
128
+ title: "Needs fix task",
129
+ status: "needs_fix",
130
+ }),
131
+ ]);
132
+ const md = generateHandoff("goal_test_001", goal);
133
+ const blockerSection = md.split("## 当前阻塞点")[1].split("##")[0];
134
+ assert.ok(blockerSection.includes("subgoal-001"));
135
+ assert.ok(blockerSection.includes("Needs fix task"));
136
+ const riskSection = md.split("## 风险提醒")[1].split("##")[0];
137
+ assert.ok(riskSection.includes("subgoal-001"));
138
+ assert.ok(riskSection.includes("needs_fix"));
139
+ });
140
+
141
+ it("有 recentDiff → 出现在最近一次 diff 摘要", () => {
142
+ const goal = makeGoalStatus([]);
143
+ const diff = "diff --git a/src/foo.ts b/src/foo.ts\n+console.log('hello')";
144
+ const md = generateHandoff("goal_test_001", goal, diff);
145
+ const diffSection = md.split("## 最近一次 diff 摘要")[1].split("##")[0];
146
+ assert.ok(diffSection.includes("src/foo.ts"));
147
+ assert.ok(diffSection.includes("console.log"));
148
+ });
149
+
150
+ it("无 recentDiff → 显示暂无", () => {
151
+ const goal = makeGoalStatus([]);
152
+ const md = generateHandoff("goal_test_001", goal);
153
+ const diffSection = md.split("## 最近一次 diff 摘要")[1].split("##")[0];
154
+ assert.ok(diffSection.includes("暂无"));
155
+ });
156
+
157
+ it("有 recentTestResult → 出现在最近一次测试结果", () => {
158
+ const goal = makeGoalStatus([]);
159
+ const testResult = "PASS: 42 tests passed, 0 failed";
160
+ const md = generateHandoff("goal_test_001", goal, undefined, testResult);
161
+ const testSection = md.split("## 最近一次测试结果")[1].split("##")[0];
162
+ assert.ok(testSection.includes("42 tests passed"));
163
+ });
164
+
165
+ it("无 recentTestResult → 显示暂无", () => {
166
+ const goal = makeGoalStatus([]);
167
+ const md = generateHandoff("goal_test_001", goal);
168
+ const testSection = md.split("## 最近一次测试结果")[1].split("##")[0];
169
+ assert.ok(testSection.includes("暂无"));
170
+ });
171
+
172
+ it("接手说明包含 goalId", () => {
173
+ const goal = makeGoalStatus([]);
174
+ const md = generateHandoff("goal_handoff_999", goal);
175
+ const handoffSection = md.split("## 接手说明")[1];
176
+ assert.ok(handoffSection.includes("goal_handoff_999"));
177
+ assert.ok(handoffSection.includes("read_goal"));
178
+ assert.ok(handoffSection.includes("suggest_next_subgoal"));
179
+ });
180
+
181
+ it("当前 Goal 章节包含 goal_id/title/status/repo_path", () => {
182
+ const goal = makeGoalStatus([], {
183
+ goal_id: "goal_abc",
184
+ title: "My Custom Goal",
185
+ status: "active",
186
+ repo_path: "/custom/repo",
187
+ });
188
+ const md = generateHandoff("goal_abc", goal);
189
+ const goalSection = md.split("## 当前 Goal")[1].split("##")[0];
190
+ assert.ok(goalSection.includes("goal_abc"));
191
+ assert.ok(goalSection.includes("My Custom Goal"));
192
+ assert.ok(goalSection.includes("active"));
193
+ assert.ok(goalSection.includes("/custom/repo"));
194
+ });
195
+
196
+ it("有 ready subgoal → 下一步建议包含该 subgoal", () => {
197
+ const goal = makeGoalStatus([
198
+ makeSubgoal({ id: "subgoal-001", title: "Ready task", depends_on: [] }),
199
+ ]);
200
+ const md = generateHandoff("goal_test_001", goal);
201
+ const suggestSection = md.split("## 下一步建议")[1].split("##")[0];
202
+ assert.ok(suggestSection.includes("subgoal-001"));
203
+ assert.ok(suggestSection.includes("Ready task"));
204
+ });
205
+
206
+ it("未完成子目标显示 depends_on", () => {
207
+ const goal = makeGoalStatus([
208
+ makeSubgoal({ id: "A", status: "accepted", accepted_at: "2026-01-01T00:00:00.000Z" }),
209
+ makeSubgoal({ id: "B", title: "Depends on A", status: "ready", depends_on: ["A"] }),
210
+ ]);
211
+ const md = generateHandoff("goal_test_001", goal);
212
+ const incompleteSection = md.split("## 未完成子目标")[1].split("##")[0];
213
+ assert.ok(incompleteSection.includes("B"));
214
+ assert.ok(incompleteSection.includes("depends_on: A"));
215
+ });
216
+ });
217
+
218
+ describe("exportHandoff", () => {
219
+ let tempDir: string;
220
+
221
+ beforeEach(() => {
222
+ tempDir = mkdtempSync(join(tmpdir(), "pw-handoff-"));
223
+ });
224
+
225
+ afterEach(() => {
226
+ rmSync(tempDir, { recursive: true, force: true });
227
+ });
228
+
229
+ it("写入 handoff.md 文件到 .patchwarden/goals/{goalId}/", () => {
230
+ const goal = makeGoalStatus([
231
+ makeSubgoal({ id: "subgoal-001", title: "Task A", depends_on: [] }),
232
+ ]);
233
+ const result = exportHandoff("goal_export_001", goal, tempDir);
234
+
235
+ const expectedPath = resolve(tempDir, ".patchwarden", "goals", "goal_export_001", "handoff.md");
236
+ assert.equal(result.handoff_path, expectedPath);
237
+ assert.ok(existsSync(expectedPath));
238
+
239
+ const fileContent = readFileSync(expectedPath, "utf-8");
240
+ assert.ok(fileContent.includes("# Goal Session Handoff"));
241
+ assert.ok(fileContent.includes("goal_export_001"));
242
+ });
243
+
244
+ it("返回的 handoff_path 是绝对路径", () => {
245
+ const goal = makeGoalStatus([]);
246
+ const result = exportHandoff("goal_export_002", goal, tempDir);
247
+ assert.ok(result.handoff_path.includes(tempDir));
248
+ assert.ok(result.handoff_path.endsWith("handoff.md"));
249
+ });
250
+
251
+ it("content_preview 不超过 500 字符(加省略号)", () => {
252
+ const goal = makeGoalStatus([
253
+ makeSubgoal({ id: "subgoal-001", title: "A".repeat(200), depends_on: [] }),
254
+ makeSubgoal({ id: "subgoal-002", title: "B".repeat(200), depends_on: [] }),
255
+ makeSubgoal({ id: "subgoal-003", title: "C".repeat(200), depends_on: [] }),
256
+ ]);
257
+ const result = exportHandoff("goal_export_003", goal, tempDir);
258
+ assert.ok(result.content_preview.length > 0);
259
+ assert.ok(result.content_preview.endsWith("..."));
260
+ assert.ok(result.content_preview.length <= 503); // 500 + "..."
261
+ });
262
+
263
+ it("content_preview 是文档前 500 字符的截取", () => {
264
+ const goal = makeGoalStatus([]);
265
+ const result = exportHandoff("goal_export_004", goal, tempDir);
266
+ const fileContent = readFileSync(result.handoff_path, "utf-8");
267
+ if (fileContent.length > 500) {
268
+ assert.equal(result.content_preview, fileContent.slice(0, 500) + "...");
269
+ } else {
270
+ assert.equal(result.content_preview, fileContent);
271
+ }
272
+ });
273
+
274
+ it("文件内容与 generateHandoff 一致", () => {
275
+ const goal = makeGoalStatus([
276
+ makeSubgoal({ id: "subgoal-001", title: "Verify content", depends_on: [] }),
277
+ ]);
278
+ const expected = generateHandoff("goal_export_005", goal);
279
+ const result = exportHandoff("goal_export_005", goal, tempDir);
280
+ const fileContent = readFileSync(result.handoff_path, "utf-8");
281
+ // 排除 Generated 时间戳行(两次调用可能跨越毫秒边界)
282
+ const stripGenerated = (s: string) => s.replace(/^Generated: .*$/m, "Generated: <ts>");
283
+ assert.equal(stripGenerated(fileContent), stripGenerated(expected));
284
+ });
285
+
286
+ it("创建多层目录结构", () => {
287
+ const goal = makeGoalStatus([]);
288
+ const result = exportHandoff("goal_deep_001", goal, tempDir);
289
+ const goalDir = resolve(tempDir, ".patchwarden", "goals", "goal_deep_001");
290
+ const stat = statSync(goalDir);
291
+ assert.ok(stat.isDirectory());
292
+ assert.ok(existsSync(result.handoff_path));
293
+ });
294
+ });
295
+ });