patchwarden 0.6.1 → 1.1.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 (261) hide show
  1. package/PatchWarden-Control-Tray.cmd +11 -0
  2. package/PatchWarden-Control.cmd +6 -0
  3. package/PatchWarden-Desktop.cmd +5 -0
  4. package/PatchWarden.cmd +1 -1
  5. package/README.en.md +112 -24
  6. package/README.md +36 -25
  7. package/Restart-PatchWarden-Control.cmd +6 -0
  8. package/Stop-PatchWarden.cmd +11 -0
  9. package/dist/agents/agentRouter.d.ts +40 -0
  10. package/dist/agents/agentRouter.js +95 -0
  11. package/dist/config.d.ts +1 -1
  12. package/dist/config.js +6 -2
  13. package/dist/controlCenter.d.ts +14 -0
  14. package/dist/controlCenter.js +2015 -0
  15. package/dist/doctor.js +35 -4
  16. package/dist/goal/acceptanceEngine.d.ts +62 -0
  17. package/dist/goal/acceptanceEngine.js +103 -0
  18. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  19. package/dist/goal/acceptanceTemplate.js +104 -0
  20. package/dist/goal/goalGraph.d.ts +58 -0
  21. package/dist/goal/goalGraph.js +189 -0
  22. package/dist/goal/goalProgress.d.ts +81 -0
  23. package/dist/goal/goalProgress.js +216 -0
  24. package/dist/goal/goalStatus.d.ts +60 -0
  25. package/dist/goal/goalStatus.js +137 -0
  26. package/dist/goal/goalStore.d.ts +79 -0
  27. package/dist/goal/goalStore.js +211 -0
  28. package/dist/goal/handoffExport.d.ts +34 -0
  29. package/dist/goal/handoffExport.js +183 -0
  30. package/dist/goal/subgoalSync.d.ts +40 -0
  31. package/dist/goal/subgoalSync.js +72 -0
  32. package/dist/goal/worktreeManager.d.ts +96 -0
  33. package/dist/goal/worktreeManager.js +292 -0
  34. package/dist/logging.d.ts +44 -0
  35. package/dist/logging.js +68 -0
  36. package/dist/release/releaseGate.d.ts +99 -0
  37. package/dist/release/releaseGate.js +475 -0
  38. package/dist/runner/postTaskCleanup.d.ts +13 -0
  39. package/dist/runner/postTaskCleanup.js +147 -0
  40. package/dist/runner/runTask.js +50 -10
  41. package/dist/security/discoveryTokenStore.d.ts +62 -0
  42. package/dist/security/discoveryTokenStore.js +100 -0
  43. package/dist/security/toolInvocationGuard.d.ts +35 -0
  44. package/dist/security/toolInvocationGuard.js +153 -0
  45. package/dist/smoke-test.js +18 -10
  46. package/dist/taskRuntime.d.ts +17 -0
  47. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  48. package/dist/test/unit/acceptance-engine.test.js +228 -0
  49. package/dist/test/unit/agent-router.test.d.ts +1 -0
  50. package/dist/test/unit/agent-router.test.js +287 -0
  51. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  52. package/dist/test/unit/audit-checks.test.js +350 -0
  53. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  54. package/dist/test/unit/diagnose-task.test.js +457 -0
  55. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  56. package/dist/test/unit/discovery-token-store.test.js +139 -0
  57. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  58. package/dist/test/unit/goal-graph.test.js +298 -0
  59. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  60. package/dist/test/unit/goal-progress.test.js +381 -0
  61. package/dist/test/unit/goal-status.test.d.ts +1 -0
  62. package/dist/test/unit/goal-status.test.js +215 -0
  63. package/dist/test/unit/goal-store.test.d.ts +1 -0
  64. package/dist/test/unit/goal-store.test.js +253 -0
  65. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  66. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  67. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  68. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  69. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  70. package/dist/test/unit/handoff-export.test.js +263 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  72. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  73. package/dist/test/unit/logging.test.js +127 -5
  74. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  75. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  76. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  77. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  78. package/dist/test/unit/release-gate.test.d.ts +1 -0
  79. package/dist/test/unit/release-gate.test.js +242 -0
  80. package/dist/test/unit/safe-views.test.d.ts +1 -0
  81. package/dist/test/unit/safe-views.test.js +171 -0
  82. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  83. package/dist/test/unit/schema-drift-check.test.js +175 -0
  84. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  85. package/dist/test/unit/subgoal-sync.test.js +183 -0
  86. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  87. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  88. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  89. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  90. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  91. package/dist/test/unit/toolSearch.test.js +571 -0
  92. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  93. package/dist/test/unit/worktree-manager.test.js +176 -0
  94. package/dist/tools/auditTask.d.ts +103 -1
  95. package/dist/tools/auditTask.js +461 -8
  96. package/dist/tools/cancelTask.js +1 -1
  97. package/dist/tools/checkReleaseGate.d.ts +21 -0
  98. package/dist/tools/checkReleaseGate.js +22 -0
  99. package/dist/tools/createTask.d.ts +18 -2
  100. package/dist/tools/createTask.js +29 -1
  101. package/dist/tools/diagnoseTask.d.ts +45 -0
  102. package/dist/tools/diagnoseTask.js +347 -0
  103. package/dist/tools/discardWorktree.d.ts +24 -0
  104. package/dist/tools/discardWorktree.js +27 -0
  105. package/dist/tools/discoverTools.d.ts +11 -0
  106. package/dist/tools/discoverTools.js +39 -0
  107. package/dist/tools/explainTool.d.ts +11 -0
  108. package/dist/tools/explainTool.js +21 -0
  109. package/dist/tools/getTaskSummary.js +2 -1
  110. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  111. package/dist/tools/goalSubgoalTask.js +110 -0
  112. package/dist/tools/healthCheck.d.ts +1 -0
  113. package/dist/tools/healthCheck.js +5 -1
  114. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  115. package/dist/tools/invokeDiscoveredTool.js +112 -0
  116. package/dist/tools/listTasks.d.ts +3 -1
  117. package/dist/tools/listTasks.js +14 -1
  118. package/dist/tools/mergeWorktree.d.ts +24 -0
  119. package/dist/tools/mergeWorktree.js +27 -0
  120. package/dist/tools/reconcileTasks.d.ts +41 -0
  121. package/dist/tools/reconcileTasks.js +352 -0
  122. package/dist/tools/registry.js +550 -0
  123. package/dist/tools/safeStatus.d.ts +31 -1
  124. package/dist/tools/safeStatus.js +43 -1
  125. package/dist/tools/safeViews.d.ts +256 -0
  126. package/dist/tools/safeViews.js +250 -0
  127. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  128. package/dist/tools/schemaDriftCheck.js +80 -0
  129. package/dist/tools/toolCatalog.d.ts +4 -3
  130. package/dist/tools/toolCatalog.js +33 -11
  131. package/dist/tools/toolRegistry.d.ts +61 -0
  132. package/dist/tools/toolRegistry.js +724 -0
  133. package/dist/tools/toolSearch.d.ts +110 -0
  134. package/dist/tools/toolSearch.js +331 -0
  135. package/dist/tools/toolUsageStats.d.ts +41 -0
  136. package/dist/tools/toolUsageStats.js +116 -0
  137. package/dist/tools/waitForTask.js +1 -0
  138. package/dist/version.d.ts +2 -2
  139. package/dist/version.js +2 -2
  140. package/dist/watcherStatus.d.ts +29 -0
  141. package/dist/watcherStatus.js +92 -1
  142. package/docs/control-center/README.md +33 -0
  143. package/docs/control-center/control-center-daily-driver.md +211 -0
  144. package/docs/control-center/control-center-mvp.md +205 -0
  145. package/docs/control-center/control-center-phase2.md +159 -0
  146. package/docs/demo.md +3 -0
  147. package/docs/release-v0.6.4.md +45 -0
  148. package/examples/openai-tunnel/README.md +5 -5
  149. package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
  150. package/package.json +25 -16
  151. package/scripts/README.md +47 -0
  152. package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
  153. package/scripts/checks/control-center-smoke.js +1098 -0
  154. package/scripts/{control-smoke.js → checks/control-smoke.js} +19 -5
  155. package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +2 -1
  156. package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +2 -2
  157. package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +25 -21
  158. package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +54 -7
  159. package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +40 -11
  160. package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
  161. package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
  162. package/scripts/{unit-tests.js → checks/unit-tests.js} +1 -1
  163. package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +13 -7
  164. package/scripts/control/control-center-tray.ps1 +281 -0
  165. package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
  166. package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
  167. package/scripts/control/restart-control-center.ps1 +173 -0
  168. package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
  169. package/scripts/control/start-control-center.ps1 +263 -0
  170. package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
  171. package/scripts/control/stop-patchwarden.ps1 +114 -0
  172. package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
  173. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
  174. package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
  175. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
  176. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
  177. package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
  178. package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
  179. package/scripts/{pack-clean.js → release/pack-clean.js} +9 -1
  180. package/src/agents/agentRouter.ts +149 -0
  181. package/src/config.ts +9 -3
  182. package/src/controlCenter.ts +2166 -0
  183. package/src/doctor.ts +40 -5
  184. package/src/goal/acceptanceEngine.ts +160 -0
  185. package/src/goal/acceptanceTemplate.ts +121 -0
  186. package/src/goal/goalGraph.ts +225 -0
  187. package/src/goal/goalProgress.ts +301 -0
  188. package/src/goal/goalStatus.ts +234 -0
  189. package/src/goal/goalStore.ts +306 -0
  190. package/src/goal/handoffExport.ts +211 -0
  191. package/src/goal/subgoalSync.ts +82 -0
  192. package/src/goal/worktreeManager.ts +404 -0
  193. package/src/logging.ts +91 -0
  194. package/src/release/releaseGate.ts +567 -0
  195. package/src/runner/postTaskCleanup.ts +154 -0
  196. package/src/runner/runTask.ts +49 -10
  197. package/src/security/discoveryTokenStore.ts +157 -0
  198. package/src/security/toolInvocationGuard.ts +251 -0
  199. package/src/smoke-test.ts +15 -7
  200. package/src/taskRuntime.ts +17 -0
  201. package/src/test/unit/acceptance-engine.test.ts +261 -0
  202. package/src/test/unit/agent-router.test.ts +342 -0
  203. package/src/test/unit/audit-checks.test.ts +567 -0
  204. package/src/test/unit/diagnose-task.test.ts +544 -0
  205. package/src/test/unit/discovery-token-store.test.ts +181 -0
  206. package/src/test/unit/goal-graph.test.ts +347 -0
  207. package/src/test/unit/goal-progress.test.ts +538 -0
  208. package/src/test/unit/goal-status.test.ts +270 -0
  209. package/src/test/unit/goal-store.test.ts +318 -0
  210. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  211. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  212. package/src/test/unit/handoff-export.test.ts +295 -0
  213. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  214. package/src/test/unit/logging.test.ts +177 -5
  215. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  216. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  217. package/src/test/unit/release-gate.test.ts +314 -0
  218. package/src/test/unit/safe-views.test.ts +184 -0
  219. package/src/test/unit/schema-drift-check.test.ts +258 -0
  220. package/src/test/unit/subgoal-sync.test.ts +236 -0
  221. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  222. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  223. package/src/test/unit/toolSearch.test.ts +756 -0
  224. package/src/test/unit/worktree-manager.test.ts +247 -0
  225. package/src/tools/auditTask.ts +831 -402
  226. package/src/tools/cancelTask.ts +1 -1
  227. package/src/tools/checkReleaseGate.ts +45 -0
  228. package/src/tools/createTask.ts +64 -6
  229. package/src/tools/diagnoseTask.ts +460 -0
  230. package/src/tools/discardWorktree.ts +42 -0
  231. package/src/tools/discoverTools.ts +51 -0
  232. package/src/tools/explainTool.ts +26 -0
  233. package/src/tools/getTaskSummary.ts +2 -1
  234. package/src/tools/goalSubgoalTask.ts +170 -0
  235. package/src/tools/healthCheck.ts +4 -1
  236. package/src/tools/invokeDiscoveredTool.ts +163 -0
  237. package/src/tools/listTasks.ts +16 -2
  238. package/src/tools/mergeWorktree.ts +42 -0
  239. package/src/tools/reconcileTasks.ts +464 -0
  240. package/src/tools/registry.ts +618 -1
  241. package/src/tools/safeStatus.ts +73 -2
  242. package/src/tools/safeViews.ts +271 -0
  243. package/src/tools/schemaDriftCheck.ts +120 -0
  244. package/src/tools/toolCatalog.ts +34 -11
  245. package/src/tools/toolRegistry.ts +786 -0
  246. package/src/tools/toolSearch.ts +464 -0
  247. package/src/tools/toolUsageStats.ts +151 -0
  248. package/src/tools/waitForTask.ts +1 -0
  249. package/src/version.ts +2 -2
  250. package/src/watcherStatus.ts +97 -1
  251. package/ui/colors_and_type.css +141 -0
  252. package/ui/pages/audit.html +743 -0
  253. package/ui/pages/dashboard.html +1154 -0
  254. package/ui/pages/direct-sessions.html +652 -0
  255. package/ui/pages/logs.html +502 -0
  256. package/ui/pages/task-detail.html +1229 -0
  257. package/ui/pages/tasks.html +702 -0
  258. package/ui/pages/workspace.html +947 -0
  259. package/ui/partials/project-shell.html +362 -0
  260. package/ui/vendor/lucide.js +12 -0
  261. package/ui/vendor/tailwindcss-browser.js +947 -0
@@ -0,0 +1,270 @@
1
+ import { describe, it } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import {
4
+ createInitialGoalStatus,
5
+ addSubgoal,
6
+ updateSubgoalStatus,
7
+ linkTaskToSubgoal,
8
+ type GoalStatus,
9
+ } from "../../goal/goalStatus.js";
10
+ import { PatchWardenError } from "../../errors.js";
11
+
12
+ // ── Helpers ───────────────────────────────────────────────────────
13
+
14
+ function makeInitialStatus(): GoalStatus {
15
+ return createInitialGoalStatus("goal_test_001", "Test Goal", "/repo/test");
16
+ }
17
+
18
+ // ── Tests ─────────────────────────────────────────────────────────
19
+
20
+ describe("goalStatus", () => {
21
+
22
+ describe("createInitialGoalStatus", () => {
23
+ it("返回正确的初始字段", () => {
24
+ const status = createInitialGoalStatus("goal_20260101_my_goal", "My Goal", "/repo/path");
25
+ assert.equal(status.goal_id, "goal_20260101_my_goal");
26
+ assert.equal(status.title, "My Goal");
27
+ assert.equal(status.status, "active");
28
+ assert.equal(status.repo_path, "/repo/path");
29
+ assert.ok(status.created_at.length > 0);
30
+ assert.equal(status.created_at, status.updated_at);
31
+ assert.deepEqual(status.subgoals, []);
32
+ });
33
+ });
34
+
35
+ describe("addSubgoal", () => {
36
+ it("成功生成 subgoal-001", () => {
37
+ const initial = makeInitialStatus();
38
+ const { goalStatus, subgoalId } = addSubgoal(initial, "First subgoal");
39
+ assert.equal(subgoalId, "subgoal-001");
40
+ assert.equal(goalStatus.subgoals.length, 1);
41
+ const sub = goalStatus.subgoals[0];
42
+ assert.equal(sub.id, "subgoal-001");
43
+ assert.equal(sub.title, "First subgoal");
44
+ assert.equal(sub.status, "ready");
45
+ assert.deepEqual(sub.depends_on, []);
46
+ assert.deepEqual(sub.task_ids, []);
47
+ });
48
+
49
+ it("多个 subgoal 编号递增", () => {
50
+ const initial = makeInitialStatus();
51
+ const r1 = addSubgoal(initial, "A");
52
+ const r2 = addSubgoal(r1.goalStatus, "B");
53
+ const r3 = addSubgoal(r2.goalStatus, "C");
54
+ assert.equal(r1.subgoalId, "subgoal-001");
55
+ assert.equal(r2.subgoalId, "subgoal-002");
56
+ assert.equal(r3.subgoalId, "subgoal-003");
57
+ assert.equal(r3.goalStatus.subgoals.length, 3);
58
+ });
59
+
60
+ it("三位补零到 010", () => {
61
+ let status = makeInitialStatus();
62
+ for (let i = 1; i <= 10; i++) {
63
+ const r = addSubgoal(status, `Sub ${i}`);
64
+ status = r.goalStatus;
65
+ if (i === 10) {
66
+ assert.equal(r.subgoalId, "subgoal-010");
67
+ }
68
+ }
69
+ });
70
+
71
+ it("无效依赖抛出 invalid_dependency", () => {
72
+ const initial = makeInitialStatus();
73
+ assert.throws(
74
+ () => addSubgoal(initial, "Bad dep", ["subgoal-999"]),
75
+ (err: unknown) => {
76
+ assert.ok(err instanceof PatchWardenError);
77
+ assert.equal(err.reason, "invalid_dependency");
78
+ return true;
79
+ }
80
+ );
81
+ });
82
+
83
+ it("有效依赖成功添加", () => {
84
+ const initial = makeInitialStatus();
85
+ const r1 = addSubgoal(initial, "Parent");
86
+ const r2 = addSubgoal(r1.goalStatus, "Child", ["subgoal-001"]);
87
+ assert.equal(r2.subgoalId, "subgoal-002");
88
+ assert.deepEqual(r2.goalStatus.subgoals[1].depends_on, ["subgoal-001"]);
89
+ });
90
+
91
+ it("不修改原 goalStatus(不可变)", () => {
92
+ const initial = makeInitialStatus();
93
+ const originalLen = initial.subgoals.length;
94
+ addSubgoal(initial, "New");
95
+ assert.equal(initial.subgoals.length, originalLen);
96
+ });
97
+
98
+ it("更新 updated_at", () => {
99
+ const initial = makeInitialStatus();
100
+ const originalUpdatedAt = initial.updated_at;
101
+ const { goalStatus } = addSubgoal(initial, "New");
102
+ // updated_at 应该是新的 ISO 时间字符串
103
+ assert.ok(goalStatus.updated_at >= originalUpdatedAt);
104
+ });
105
+ });
106
+
107
+ describe("updateSubgoalStatus", () => {
108
+ it("合法转换 ready → running", () => {
109
+ let status = makeInitialStatus();
110
+ const r = addSubgoal(status, "Sub");
111
+ status = r.goalStatus;
112
+ const updated = updateSubgoalStatus(status, "subgoal-001", "running");
113
+ assert.equal(updated.subgoals[0].status, "running");
114
+ });
115
+
116
+ it("合法完整链路 ready → running → done_by_agent → accepted", () => {
117
+ let status = makeInitialStatus();
118
+ status = addSubgoal(status, "Sub").goalStatus;
119
+ status = updateSubgoalStatus(status, "subgoal-001", "running");
120
+ status = updateSubgoalStatus(status, "subgoal-001", "done_by_agent");
121
+ status = updateSubgoalStatus(status, "subgoal-001", "accepted");
122
+ assert.equal(status.subgoals[0].status, "accepted");
123
+ assert.ok(status.subgoals[0].accepted_at !== undefined);
124
+ assert.ok(status.subgoals[0].accepted_at!.length > 0);
125
+ });
126
+
127
+ it("done_by_agent → rejected 需要 reason", () => {
128
+ let status = makeInitialStatus();
129
+ status = addSubgoal(status, "Sub").goalStatus;
130
+ status = updateSubgoalStatus(status, "subgoal-001", "running");
131
+ status = updateSubgoalStatus(status, "subgoal-001", "done_by_agent");
132
+
133
+ // 缺少 reason 抛错
134
+ assert.throws(
135
+ () => updateSubgoalStatus(status, "subgoal-001", "rejected"),
136
+ (err: unknown) => {
137
+ assert.ok(err instanceof PatchWardenError);
138
+ assert.equal(err.reason, "invalid_status_transition");
139
+ return true;
140
+ }
141
+ );
142
+
143
+ // 提供 reason 成功
144
+ const updated = updateSubgoalStatus(status, "subgoal-001", "rejected", {
145
+ rejected_reason: "Tests failed",
146
+ });
147
+ assert.equal(updated.subgoals[0].status, "rejected");
148
+ assert.equal(updated.subgoals[0].rejected_reason, "Tests failed");
149
+ });
150
+
151
+ it("合法分支 done_by_agent → needs_fix → running", () => {
152
+ let status = makeInitialStatus();
153
+ status = addSubgoal(status, "Sub").goalStatus;
154
+ status = updateSubgoalStatus(status, "subgoal-001", "running");
155
+ status = updateSubgoalStatus(status, "subgoal-001", "done_by_agent");
156
+ status = updateSubgoalStatus(status, "subgoal-001", "needs_fix");
157
+ assert.equal(status.subgoals[0].status, "needs_fix");
158
+ status = updateSubgoalStatus(status, "subgoal-001", "running");
159
+ assert.equal(status.subgoals[0].status, "running");
160
+ });
161
+
162
+ it("非法转换 ready → accepted 抛错", () => {
163
+ const initial = makeInitialStatus();
164
+ const status = addSubgoal(initial, "Sub").goalStatus;
165
+ assert.throws(
166
+ () => updateSubgoalStatus(status, "subgoal-001", "accepted"),
167
+ (err: unknown) => {
168
+ assert.ok(err instanceof PatchWardenError);
169
+ assert.equal(err.reason, "invalid_status_transition");
170
+ return true;
171
+ }
172
+ );
173
+ });
174
+
175
+ it("非法转换 running → ready 抛错", () => {
176
+ let status = makeInitialStatus();
177
+ status = addSubgoal(status, "Sub").goalStatus;
178
+ status = updateSubgoalStatus(status, "subgoal-001", "running");
179
+ assert.throws(
180
+ () => updateSubgoalStatus(status, "subgoal-001", "ready"),
181
+ (err: unknown) => {
182
+ assert.ok(err instanceof PatchWardenError);
183
+ assert.equal(err.reason, "invalid_status_transition");
184
+ return true;
185
+ }
186
+ );
187
+ });
188
+
189
+ it("终态 accepted → running 抛错", () => {
190
+ let status = makeInitialStatus();
191
+ status = addSubgoal(status, "Sub").goalStatus;
192
+ status = updateSubgoalStatus(status, "subgoal-001", "running");
193
+ status = updateSubgoalStatus(status, "subgoal-001", "done_by_agent");
194
+ status = updateSubgoalStatus(status, "subgoal-001", "accepted");
195
+ assert.throws(
196
+ () => updateSubgoalStatus(status, "subgoal-001", "running"),
197
+ (err: unknown) => {
198
+ assert.ok(err instanceof PatchWardenError);
199
+ assert.equal(err.reason, "invalid_status_transition");
200
+ return true;
201
+ }
202
+ );
203
+ });
204
+
205
+ it("subgoal 不存在抛出 subgoal_not_found", () => {
206
+ const initial = makeInitialStatus();
207
+ assert.throws(
208
+ () => updateSubgoalStatus(initial, "subgoal-999", "running"),
209
+ (err: unknown) => {
210
+ assert.ok(err instanceof PatchWardenError);
211
+ assert.equal(err.reason, "subgoal_not_found");
212
+ return true;
213
+ }
214
+ );
215
+ });
216
+
217
+ it("不修改原 goalStatus(不可变)", () => {
218
+ let status = makeInitialStatus();
219
+ status = addSubgoal(status, "Sub").goalStatus;
220
+ const originalStatus = status.subgoals[0].status;
221
+ updateSubgoalStatus(status, "subgoal-001", "running");
222
+ assert.equal(status.subgoals[0].status, originalStatus);
223
+ });
224
+ });
225
+
226
+ describe("linkTaskToSubgoal", () => {
227
+ it("成功追加 taskId", () => {
228
+ let status = makeInitialStatus();
229
+ status = addSubgoal(status, "Sub").goalStatus;
230
+ const updated = linkTaskToSubgoal(status, "subgoal-001", "task_abc");
231
+ assert.deepEqual(updated.subgoals[0].task_ids, ["task_abc"]);
232
+ });
233
+
234
+ it("追加多个 taskId", () => {
235
+ let status = makeInitialStatus();
236
+ status = addSubgoal(status, "Sub").goalStatus;
237
+ status = linkTaskToSubgoal(status, "subgoal-001", "task_a");
238
+ status = linkTaskToSubgoal(status, "subgoal-001", "task_b");
239
+ assert.deepEqual(status.subgoals[0].task_ids, ["task_a", "task_b"]);
240
+ });
241
+
242
+ it("去重:相同 taskId 不重复添加", () => {
243
+ let status = makeInitialStatus();
244
+ status = addSubgoal(status, "Sub").goalStatus;
245
+ status = linkTaskToSubgoal(status, "subgoal-001", "task_a");
246
+ status = linkTaskToSubgoal(status, "subgoal-001", "task_a");
247
+ assert.deepEqual(status.subgoals[0].task_ids, ["task_a"]);
248
+ });
249
+
250
+ it("subgoal 不存在抛出 subgoal_not_found", () => {
251
+ const initial = makeInitialStatus();
252
+ assert.throws(
253
+ () => linkTaskToSubgoal(initial, "subgoal-999", "task_a"),
254
+ (err: unknown) => {
255
+ assert.ok(err instanceof PatchWardenError);
256
+ assert.equal(err.reason, "subgoal_not_found");
257
+ return true;
258
+ }
259
+ );
260
+ });
261
+
262
+ it("不修改原 goalStatus(不可变)", () => {
263
+ let status = makeInitialStatus();
264
+ status = addSubgoal(status, "Sub").goalStatus;
265
+ const originalLen = status.subgoals[0].task_ids.length;
266
+ linkTaskToSubgoal(status, "subgoal-001", "task_a");
267
+ assert.equal(status.subgoals[0].task_ids.length, originalLen);
268
+ });
269
+ });
270
+ });
@@ -0,0 +1,318 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, mkdirSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import {
7
+ generateGoalId,
8
+ getGoalsDir,
9
+ getGoalDir,
10
+ createGoal,
11
+ listGoals,
12
+ readGoal,
13
+ writeGoalStatus,
14
+ readGoalStatus,
15
+ } from "../../goal/goalStore.js";
16
+ import { addSubgoal, updateSubgoalStatus } from "../../goal/goalStatus.js";
17
+ import { PatchWardenError } from "../../errors.js";
18
+
19
+ // ── Helpers ───────────────────────────────────────────────────────
20
+
21
+ let tempDir: string;
22
+
23
+ beforeEach(() => {
24
+ tempDir = mkdtempSync(join(tmpdir(), "pw-goalstore-"));
25
+ });
26
+
27
+ afterEach(() => {
28
+ rmSync(tempDir, { recursive: true, force: true });
29
+ });
30
+
31
+ function todayDatePart(): string {
32
+ const now = new Date();
33
+ const y = now.getFullYear();
34
+ const m = String(now.getMonth() + 1).padStart(2, "0");
35
+ const d = String(now.getDate()).padStart(2, "0");
36
+ return `${y}${m}${d}`;
37
+ }
38
+
39
+ // ── Tests ─────────────────────────────────────────────────────────
40
+
41
+ describe("goalStore", () => {
42
+
43
+ describe("generateGoalId", () => {
44
+ it("正常 title 生成 goal_{date}_{slug}", () => {
45
+ const id = generateGoalId("Implement Feature X", []);
46
+ assert.equal(id, `goal_${todayDatePart()}_implement_feature_x`);
47
+ });
48
+
49
+ it("特殊字符 title 转换为 slug", () => {
50
+ const id = generateGoalId("Fix: Bug #123 & Refactor!", []);
51
+ assert.equal(id, `goal_${todayDatePart()}_fix_bug_123_refactor`);
52
+ });
53
+
54
+ it("空 title 用 untitled 代替", () => {
55
+ const id = generateGoalId("", []);
56
+ assert.equal(id, `goal_${todayDatePart()}_untitled`);
57
+ });
58
+
59
+ it("纯符号 title 用 untitled 代替", () => {
60
+ const id = generateGoalId("!!!@#$%^&*()", []);
61
+ assert.equal(id, `goal_${todayDatePart()}_untitled`);
62
+ });
63
+
64
+ it("title 截断到 30 字符 slug", () => {
65
+ const longTitle = "abcdefghijklmnopqrstuvwxyz0123456789";
66
+ const id = generateGoalId(longTitle, []);
67
+ const slug = id.split("_").slice(2).join("_");
68
+ assert.ok(slug.length <= 30, `slug length should be <= 30, got ${slug.length}`);
69
+ });
70
+
71
+ it("冲突时追加 _2", () => {
72
+ const base = `goal_${todayDatePart()}_implement_feature_x`;
73
+ const id = generateGoalId("Implement Feature X", [base]);
74
+ assert.equal(id, `${base}_2`);
75
+ });
76
+
77
+ it("多次冲突递增 _3", () => {
78
+ const base = `goal_${todayDatePart()}_implement_feature_x`;
79
+ const id = generateGoalId("Implement Feature X", [base, `${base}_2`]);
80
+ assert.equal(id, `${base}_3`);
81
+ });
82
+
83
+ it("不冲突时不追加后缀", () => {
84
+ const base = `goal_${todayDatePart()}_implement_feature_x`;
85
+ const other = `goal_${todayDatePart()}_other_goal`;
86
+ const id = generateGoalId("Implement Feature X", [other]);
87
+ assert.equal(id, base);
88
+ });
89
+ });
90
+
91
+ describe("getGoalsDir / getGoalDir", () => {
92
+ it("getGoalsDir 返回 .patchwarden/goals 路径", () => {
93
+ const dir = getGoalsDir(tempDir);
94
+ assert.equal(dir, join(tempDir, ".patchwarden", "goals"));
95
+ });
96
+
97
+ it("getGoalDir 返回 {goalsDir}/{goalId}", () => {
98
+ const dir = getGoalDir("goal_001", tempDir);
99
+ assert.equal(dir, join(tempDir, ".patchwarden", "goals", "goal_001"));
100
+ });
101
+ });
102
+
103
+ describe("createGoal", () => {
104
+ it("成功创建目录结构和文件", () => {
105
+ const repoRel = "my-repo";
106
+ const { goal_id, goal_dir } = createGoal(repoRel, "Test Goal", "A test description", tempDir);
107
+ assert.ok(goal_id.startsWith(`goal_${todayDatePart()}_test_goal`));
108
+ assert.equal(goal_dir, join(tempDir, ".patchwarden", "goals", goal_id));
109
+
110
+ // 验证目录结构
111
+ assert.ok(existsSync(goal_dir), "goal_dir exists");
112
+ assert.ok(existsSync(join(goal_dir, "tasks")), "tasks/ exists");
113
+ assert.ok(existsSync(join(goal_dir, "artifacts")), "artifacts/ exists");
114
+
115
+ // 验证 GOAL.md
116
+ const goalMd = readFileSync(join(goal_dir, "GOAL.md"), "utf-8");
117
+ assert.ok(goalMd.includes("# Test Goal"));
118
+ assert.ok(goalMd.includes("A test description"));
119
+ assert.ok(goalMd.includes("- Status: active"));
120
+ assert.ok(goalMd.includes("- Repo:"));
121
+
122
+ // 验证 GOALS.md
123
+ const goalsMd = readFileSync(join(goal_dir, "GOALS.md"), "utf-8");
124
+ assert.ok(goalsMd.includes("# Subgoals: Test Goal"));
125
+ assert.ok(goalsMd.includes("_No subgoals yet._"));
126
+
127
+ // 验证 goal_status.json
128
+ const statusPath = join(goal_dir, "goal_status.json");
129
+ assert.ok(existsSync(statusPath));
130
+ const status = JSON.parse(readFileSync(statusPath, "utf-8"));
131
+ assert.equal(status.goal_id, goal_id);
132
+ assert.equal(status.title, "Test Goal");
133
+ assert.equal(status.status, "active");
134
+ assert.deepEqual(status.subgoals, []);
135
+ });
136
+
137
+ it("repo_path 越界抛出 workspace_path_escape", () => {
138
+ const outside = process.platform === "win32" ? "C:\\Windows\\System32" : "/etc";
139
+ assert.throws(
140
+ () => createGoal(outside, "Bad", "desc", tempDir),
141
+ (err: unknown) => {
142
+ assert.ok(err instanceof PatchWardenError);
143
+ assert.equal(err.reason, "workspace_path_escape");
144
+ return true;
145
+ }
146
+ );
147
+ });
148
+
149
+ it("相同 title 创建多个 goal 时 id 不冲突", () => {
150
+ const r1 = createGoal("repo", "Same Title", "d1", tempDir);
151
+ const r2 = createGoal("repo", "Same Title", "d2", tempDir);
152
+ assert.notEqual(r1.goal_id, r2.goal_id);
153
+ assert.ok(r2.goal_id.endsWith("_2"));
154
+ });
155
+ });
156
+
157
+ describe("listGoals", () => {
158
+ it("空目录返回空数组", () => {
159
+ const list = listGoals(tempDir);
160
+ assert.deepEqual(list, []);
161
+ });
162
+
163
+ it("goals 目录不存在返回空数组", () => {
164
+ // tempDir 中没有 .patchwarden/goals
165
+ const list = listGoals(tempDir);
166
+ assert.deepEqual(list, []);
167
+ });
168
+
169
+ it("返回多个 goal 的摘要", () => {
170
+ createGoal("repo", "Goal A", "desc A", tempDir);
171
+ createGoal("repo", "Goal B", "desc B", tempDir);
172
+ const list = listGoals(tempDir);
173
+ assert.equal(list.length, 2);
174
+ const titles = list.map((g) => g.title).sort();
175
+ assert.deepEqual(titles, ["Goal A", "Goal B"]);
176
+ });
177
+
178
+ it("按 updated_at 降序排列", async () => {
179
+ const r1 = createGoal("repo", "Older", "desc", tempDir);
180
+ // 等待一小段时间确保 updated_at 不同
181
+ await new Promise((resolve) => setTimeout(resolve, 50));
182
+ const r2 = createGoal("repo", "Newer", "desc", tempDir);
183
+
184
+ // 修改 older 的 status,使其 updated_at 更新
185
+ const olderStatus = readGoalStatus(r1.goal_id, tempDir);
186
+ const newerStatus = readGoalStatus(r2.goal_id, tempDir);
187
+
188
+ // 让 older 的 updated_at 比 newer 更晚
189
+ const future = new Date(Date.now() + 10000).toISOString();
190
+ writeGoalStatus(r1.goal_id, { ...olderStatus, updated_at: future }, tempDir);
191
+
192
+ const list = listGoals(tempDir);
193
+ assert.equal(list.length, 2);
194
+ assert.equal(list[0].goal_id, r1.goal_id);
195
+ assert.equal(list[1].goal_id, r2.goal_id);
196
+ assert.ok(list[0].updated_at >= list[1].updated_at);
197
+ });
198
+
199
+ it("subgoal 摘要正确计算", () => {
200
+ const { goal_id } = createGoal("repo", "With Subs", "desc", tempDir);
201
+ let status = readGoalStatus(goal_id, tempDir);
202
+ status = addSubgoal(status, "S1").goalStatus;
203
+ status = updateSubgoalStatus(status, "subgoal-001", "running");
204
+ status = addSubgoal(status, "S2").goalStatus;
205
+ status = updateSubgoalStatus(status, "subgoal-002", "running");
206
+ status = updateSubgoalStatus(status, "subgoal-002", "done_by_agent");
207
+ status = updateSubgoalStatus(status, "subgoal-002", "accepted");
208
+ writeGoalStatus(goal_id, status, tempDir);
209
+
210
+ const list = listGoals(tempDir);
211
+ assert.equal(list.length, 1);
212
+ const item = list[0];
213
+ assert.equal(item.subgoal_total, 2);
214
+ assert.equal(item.subgoal_accepted, 1);
215
+ assert.equal(item.subgoal_running, 1);
216
+ });
217
+
218
+ it("跳过无法解析的目录", () => {
219
+ // 创建一个有效的 goal
220
+ createGoal("repo", "Valid", "desc", tempDir);
221
+ // 创建一个无效的目录(没有 goal_status.json)
222
+ const goalsDir = getGoalsDir(tempDir);
223
+ const badDir = join(goalsDir, "goal_bad_9999_invalid");
224
+ mkdirSync(badDir, { recursive: true });
225
+
226
+ const list = listGoals(tempDir);
227
+ assert.equal(list.length, 1);
228
+ assert.equal(list[0].title, "Valid");
229
+ });
230
+ });
231
+
232
+ describe("readGoal", () => {
233
+ it("返回完整详情", () => {
234
+ const { goal_id } = createGoal("repo", "Read Me", "description here", tempDir);
235
+ const detail = readGoal(goal_id, tempDir);
236
+ assert.equal(detail.goal_id, goal_id);
237
+ assert.equal(detail.title, "Read Me");
238
+ assert.equal(detail.status, "active");
239
+ assert.equal(detail.created_at, detail.updated_at);
240
+ assert.ok(detail.goal_description.includes("# Read Me"));
241
+ assert.ok(detail.goal_description.includes("description here"));
242
+ assert.deepEqual(detail.subgoals, []);
243
+ });
244
+
245
+ it("goal 不存在抛出 goal_not_found", () => {
246
+ assert.throws(
247
+ () => readGoal("goal_nonexistent", tempDir),
248
+ (err: unknown) => {
249
+ assert.ok(err instanceof PatchWardenError);
250
+ assert.equal(err.reason, "goal_not_found");
251
+ return true;
252
+ }
253
+ );
254
+ });
255
+
256
+ it("goal_status.json 不存在抛出 goal_not_found", () => {
257
+ // 手动创建一个 goal 目录但不写 status
258
+ const goalsDir = getGoalsDir(tempDir);
259
+ const emptyGoalDir = join(goalsDir, "goal_empty");
260
+ mkdirSync(emptyGoalDir, { recursive: true });
261
+ assert.throws(
262
+ () => readGoal("goal_empty", tempDir),
263
+ (err: unknown) => {
264
+ assert.ok(err instanceof PatchWardenError);
265
+ assert.equal(err.reason, "goal_not_found");
266
+ return true;
267
+ }
268
+ );
269
+ });
270
+ });
271
+
272
+ describe("writeGoalStatus + readGoalStatus", () => {
273
+ it("写入后读取一致", () => {
274
+ const { goal_id } = createGoal("repo", "Round Trip", "desc", tempDir);
275
+ const original = readGoalStatus(goal_id, tempDir);
276
+ const withSub = addSubgoal(original, "New Sub").goalStatus;
277
+ writeGoalStatus(goal_id, withSub, tempDir);
278
+
279
+ const read = readGoalStatus(goal_id, tempDir);
280
+ assert.equal(read.subgoals.length, 1);
281
+ assert.equal(read.subgoals[0].id, "subgoal-001");
282
+ assert.equal(read.subgoals[0].title, "New Sub");
283
+ assert.equal(read.subgoals[0].status, "ready");
284
+ });
285
+
286
+ it("原子写:tmp 文件不留", () => {
287
+ const { goal_id, goal_dir } = createGoal("repo", "Atomic", "desc", tempDir);
288
+ const status = readGoalStatus(goal_id, tempDir);
289
+ writeGoalStatus(goal_id, status, tempDir);
290
+
291
+ const entries = readdirSync(goal_dir);
292
+ assert.ok(!entries.includes("goal_status.json.tmp"), "tmp file should not remain");
293
+ assert.ok(entries.includes("goal_status.json"), "final file should exist");
294
+ });
295
+
296
+ it("readGoalStatus 文件不存在抛出 goal_not_found", () => {
297
+ assert.throws(
298
+ () => readGoalStatus("goal_nonexistent", tempDir),
299
+ (err: unknown) => {
300
+ assert.ok(err instanceof PatchWardenError);
301
+ assert.equal(err.reason, "goal_not_found");
302
+ return true;
303
+ }
304
+ );
305
+ });
306
+
307
+ it("JSON 格式化(2 空格缩进 + 末尾换行)", () => {
308
+ const { goal_id, goal_dir } = createGoal("repo", "Format", "desc", tempDir);
309
+ const status = readGoalStatus(goal_id, tempDir);
310
+ writeGoalStatus(goal_id, status, tempDir);
311
+ const raw = readFileSync(join(goal_dir, "goal_status.json"), "utf-8");
312
+ // 末尾换行
313
+ assert.ok(raw.endsWith("\n"), "should end with newline");
314
+ // 2 空格缩进
315
+ assert.ok(raw.includes('\n "goal_id"'), "should use 2-space indent");
316
+ });
317
+ });
318
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { createSubgoalTask } from "../../tools/goalSubgoalTask.js";
4
+ import { PatchWardenError } from "../../errors.js";
5
+
6
+ // ── Tests ─────────────────────────────────────────────────────────
7
+ //
8
+ // 说明:createSubgoalTask 内部调用 createTask,后者使用 getConfig()(无 workspaceRoot
9
+ // 覆盖),因此 happy-path 依赖真实配置的工作区,留待集成测试覆盖。
10
+ // 此处仅测试不触及 createTask 完整流程的错误路径:
11
+ // - invalid_execution_mode:在读取 goal 之前即抛错
12
+ // - goal_not_found:readGoalStatus 在默认工作区找不到 goal 即抛错
13
+
14
+ describe("createSubgoalTask", () => {
15
+
16
+ describe("错误路径", () => {
17
+ it("execution_mode=assess_only 抛 invalid_execution_mode", () => {
18
+ assert.throws(
19
+ () =>
20
+ createSubgoalTask({
21
+ goal_id: "goal_nonexistent_test",
22
+ subgoal_title: "Sub A",
23
+ repo_path: "repo",
24
+ execution_mode: "assess_only",
25
+ }),
26
+ (err: unknown) => {
27
+ assert.ok(err instanceof PatchWardenError);
28
+ assert.equal(err.reason, "invalid_execution_mode");
29
+ assert.equal(err.blocked, true);
30
+ return true;
31
+ }
32
+ );
33
+ });
34
+
35
+ it("goal_id 不存在抛 goal_not_found", () => {
36
+ assert.throws(
37
+ () =>
38
+ createSubgoalTask({
39
+ goal_id: "goal_definitely_does_not_exist_99999",
40
+ subgoal_title: "Sub A",
41
+ repo_path: "repo",
42
+ }),
43
+ (err: unknown) => {
44
+ assert.ok(err instanceof PatchWardenError);
45
+ assert.equal(err.reason, "goal_not_found");
46
+ assert.equal(err.blocked, true);
47
+ assert.ok(err.details.goal_id === "goal_definitely_does_not_exist_99999");
48
+ return true;
49
+ }
50
+ );
51
+ });
52
+
53
+ it("assess_only 优先于 goal_not_found(在读取 goal 前校验)", () => {
54
+ // execution_mode=assess_only 应先抛 invalid_execution_mode,
55
+ // 而不是去读不存在的 goal
56
+ assert.throws(
57
+ () =>
58
+ createSubgoalTask({
59
+ goal_id: "goal_definitely_does_not_exist_99999",
60
+ subgoal_title: "Sub A",
61
+ repo_path: "repo",
62
+ execution_mode: "assess_only",
63
+ }),
64
+ (err: unknown) => {
65
+ assert.ok(err instanceof PatchWardenError);
66
+ assert.equal(err.reason, "invalid_execution_mode");
67
+ return true;
68
+ }
69
+ );
70
+ });
71
+ });
72
+ });