patchwarden 1.1.0 → 1.5.1

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 (258) hide show
  1. package/README.en.md +83 -8
  2. package/README.md +78 -10
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2001
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/direct/directSessionStore.d.ts +2 -0
  44. package/dist/direct/directVerification.js +7 -0
  45. package/dist/doctor.d.ts +18 -1
  46. package/dist/doctor.js +579 -348
  47. package/dist/goal/goalReport.d.ts +54 -0
  48. package/dist/goal/goalReport.js +204 -0
  49. package/dist/goal/goalStatus.d.ts +6 -0
  50. package/dist/goal/specKitImport.d.ts +63 -0
  51. package/dist/goal/specKitImport.js +220 -0
  52. package/dist/goal/subgoalSync.js +2 -1
  53. package/dist/httpServer.js +15 -12
  54. package/dist/index.js +7 -4
  55. package/dist/logging.d.ts +7 -1
  56. package/dist/logging.js +8 -0
  57. package/dist/policy/projectPolicy.d.ts +55 -0
  58. package/dist/policy/projectPolicy.js +286 -0
  59. package/dist/runner/changeCapture.d.ts +3 -3
  60. package/dist/runner/changeCapture.js +63 -39
  61. package/dist/runner/cli.js +7 -6
  62. package/dist/runner/postTaskCleanup.js +26 -2
  63. package/dist/runner/runTask.js +245 -221
  64. package/dist/runner/simpleProcess.js +4 -4
  65. package/dist/runner/watch.js +17 -14
  66. package/dist/security/contentRedaction.d.ts +6 -0
  67. package/dist/security/contentRedaction.js +22 -0
  68. package/dist/smoke-test.js +261 -255
  69. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  70. package/dist/test/unit/apply-patch.test.js +225 -0
  71. package/dist/test/unit/create-task.test.d.ts +1 -0
  72. package/dist/test/unit/create-task.test.js +197 -0
  73. package/dist/test/unit/direct-guards.test.js +124 -9
  74. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  75. package/dist/test/unit/evidence-pack.test.js +224 -0
  76. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  77. package/dist/test/unit/get-task-status.test.js +174 -0
  78. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  79. package/dist/test/unit/get-task-summary.test.js +146 -0
  80. package/dist/test/unit/goal-report.test.d.ts +1 -0
  81. package/dist/test/unit/goal-report.test.js +159 -0
  82. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  83. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  84. package/dist/test/unit/path-guard.test.js +24 -0
  85. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  86. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  87. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  88. package/dist/test/unit/run-task-loop.test.js +380 -0
  89. package/dist/test/unit/schema-drift-check.test.js +10 -9
  90. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  91. package/dist/test/unit/spec-kit-import.test.js +341 -0
  92. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  93. package/dist/test/unit/wait-for-task.test.js +144 -0
  94. package/dist/tools/auditTask.d.ts +8 -63
  95. package/dist/tools/auditTask.js +12 -8
  96. package/dist/tools/createDirectSession.d.ts +1 -1
  97. package/dist/tools/createDirectSession.js +2 -2
  98. package/dist/tools/createTask.d.ts +2 -2
  99. package/dist/tools/createTask.js +4 -4
  100. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  101. package/dist/tools/dispatch/coreDispatch.js +282 -0
  102. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  103. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  104. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  105. package/dist/tools/dispatch/directDispatch.js +115 -0
  106. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  107. package/dist/tools/dispatch/goalDispatch.js +91 -0
  108. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  109. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  110. package/dist/tools/dispatch/types.d.ts +23 -0
  111. package/dist/tools/dispatch/types.js +15 -0
  112. package/dist/tools/evidencePack.d.ts +45 -0
  113. package/dist/tools/evidencePack.js +375 -0
  114. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  115. package/dist/tools/finalizeDirectSession.js +3 -3
  116. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  117. package/dist/tools/goalSubgoalTask.js +2 -2
  118. package/dist/tools/healthCheck.js +3 -3
  119. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  120. package/dist/tools/recommendAgentForTask.js +56 -0
  121. package/dist/tools/registry.d.ts +3 -3
  122. package/dist/tools/registry.js +333 -402
  123. package/dist/tools/releaseMode.d.ts +50 -0
  124. package/dist/tools/releaseMode.js +370 -0
  125. package/dist/tools/retryTask.d.ts +2 -2
  126. package/dist/tools/retryTask.js +2 -2
  127. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  128. package/dist/tools/runDirectVerificationBundle.js +64 -0
  129. package/dist/tools/runTaskLoop.d.ts +57 -0
  130. package/dist/tools/runTaskLoop.js +417 -0
  131. package/dist/tools/runVerification.d.ts +4 -0
  132. package/dist/tools/runVerification.js +4 -0
  133. package/dist/tools/safeViews.d.ts +8 -2
  134. package/dist/tools/safeViews.js +4 -2
  135. package/dist/tools/taskLineage.d.ts +91 -0
  136. package/dist/tools/taskLineage.js +175 -0
  137. package/dist/tools/toolCatalog.d.ts +2 -2
  138. package/dist/tools/toolCatalog.js +6 -0
  139. package/dist/tools/toolRegistry.js +132 -0
  140. package/dist/version.d.ts +2 -2
  141. package/dist/version.js +2 -2
  142. package/docs/CODE_WIKI.md +983 -0
  143. package/docs/agentseal-integration.md +150 -0
  144. package/docs/architecture.md +63 -0
  145. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  146. package/docs/chatgpt-usage.md +31 -0
  147. package/docs/control-center/README.md +9 -0
  148. package/docs/dashboard-overview.md +86 -0
  149. package/docs/demo.md +8 -0
  150. package/docs/direct-session-workflow.md +98 -0
  151. package/docs/evidence-pack-schema.md +215 -0
  152. package/docs/execution-plan-2026-07-09.md +315 -0
  153. package/docs/lineage-evidence-pack-workflow.md +127 -0
  154. package/docs/mcp-inspector-testing.md +200 -0
  155. package/docs/open-source-application.md +162 -0
  156. package/docs/opencode-worker.md +151 -0
  157. package/docs/openhands-worker.md +181 -0
  158. package/docs/release-evidence.md +72 -0
  159. package/docs/roadmap-execution-and-acceptance.md +365 -0
  160. package/docs/spec-kit-integration.md +131 -0
  161. package/docs/task-safe-review-workflow.md +98 -0
  162. package/docs/threat-model.md +79 -0
  163. package/docs/user-feedback.md +40 -0
  164. package/docs/why-patchwarden.md +110 -0
  165. package/package.json +3 -3
  166. package/scripts/checks/control-center-smoke.js +443 -0
  167. package/scripts/checks/control-smoke.js +2 -2
  168. package/scripts/checks/lifecycle-smoke.js +29 -23
  169. package/scripts/checks/mcp-manifest-check.js +12 -0
  170. package/scripts/checks/mcp-smoke.js +33 -7
  171. package/scripts/checks/watcher-supervisor-smoke.js +1 -1
  172. package/scripts/generate-demo-gif.py +320 -0
  173. package/src/assessments/agentAssessor.ts +3 -3
  174. package/src/assessments/assessmentStore.ts +2 -2
  175. package/src/assessments/confirmCli.ts +5 -4
  176. package/src/config.ts +37 -0
  177. package/src/control/middleware/auth.ts +17 -0
  178. package/src/control/middleware/static.ts +71 -0
  179. package/src/control/routes/audit.ts +321 -0
  180. package/src/control/routes/evidence.ts +107 -0
  181. package/src/control/routes/lineage.ts +92 -0
  182. package/src/control/routes/policy.ts +81 -0
  183. package/src/control/routes/process.ts +204 -0
  184. package/src/control/routes/sessions.ts +251 -0
  185. package/src/control/routes/status.ts +325 -0
  186. package/src/control/routes/taskActions.ts +248 -0
  187. package/src/control/routes/tasks.ts +323 -0
  188. package/src/control/routes/workspace.ts +203 -0
  189. package/src/control/runtime.ts +472 -0
  190. package/src/control/server.ts +471 -0
  191. package/src/control/shared.ts +294 -0
  192. package/src/controlCenter.ts +7 -2150
  193. package/src/direct/directGuards.ts +28 -7
  194. package/src/direct/directSessionStore.ts +2 -0
  195. package/src/direct/directVerification.ts +7 -0
  196. package/src/doctor.ts +741 -481
  197. package/src/goal/goalReport.ts +271 -0
  198. package/src/goal/goalStatus.ts +6 -0
  199. package/src/goal/specKitImport.ts +355 -0
  200. package/src/goal/subgoalSync.ts +4 -2
  201. package/src/httpServer.ts +17 -14
  202. package/src/index.ts +7 -4
  203. package/src/logging.ts +10 -1
  204. package/src/policy/projectPolicy.ts +344 -0
  205. package/src/runner/changeCapture.ts +70 -42
  206. package/src/runner/cli.ts +7 -6
  207. package/src/runner/postTaskCleanup.ts +26 -2
  208. package/src/runner/runTask.ts +325 -223
  209. package/src/runner/simpleProcess.ts +4 -4
  210. package/src/runner/watch.ts +17 -14
  211. package/src/security/contentRedaction.ts +29 -0
  212. package/src/smoke-test.ts +254 -252
  213. package/src/test/unit/apply-patch.test.ts +293 -0
  214. package/src/test/unit/create-task.test.ts +255 -0
  215. package/src/test/unit/direct-guards.test.ts +178 -8
  216. package/src/test/unit/evidence-pack.test.ts +251 -0
  217. package/src/test/unit/get-task-status.test.ts +203 -0
  218. package/src/test/unit/get-task-summary.test.ts +173 -0
  219. package/src/test/unit/goal-report.test.ts +189 -0
  220. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  221. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  222. package/src/test/unit/path-guard.test.ts +30 -0
  223. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  224. package/src/test/unit/run-task-loop.test.ts +425 -0
  225. package/src/test/unit/schema-drift-check.test.ts +11 -9
  226. package/src/test/unit/spec-kit-import.test.ts +429 -0
  227. package/src/test/unit/wait-for-task.test.ts +176 -0
  228. package/src/tools/auditTask.ts +99 -59
  229. package/src/tools/createDirectSession.ts +3 -3
  230. package/src/tools/createTask.ts +7 -7
  231. package/src/tools/dispatch/coreDispatch.ts +374 -0
  232. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  233. package/src/tools/dispatch/directDispatch.ts +167 -0
  234. package/src/tools/dispatch/goalDispatch.ts +127 -0
  235. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  236. package/src/tools/dispatch/types.ts +24 -0
  237. package/src/tools/evidencePack.ts +490 -0
  238. package/src/tools/finalizeDirectSession.ts +4 -4
  239. package/src/tools/goalSubgoalTask.ts +2 -2
  240. package/src/tools/healthCheck.ts +3 -3
  241. package/src/tools/listWorkspace.ts +71 -71
  242. package/src/tools/recommendAgentForTask.ts +79 -0
  243. package/src/tools/registry.ts +352 -509
  244. package/src/tools/releaseMode.ts +450 -0
  245. package/src/tools/retryTask.ts +2 -2
  246. package/src/tools/runDirectVerificationBundle.ts +98 -0
  247. package/src/tools/runTaskLoop.ts +526 -0
  248. package/src/tools/runVerification.ts +8 -0
  249. package/src/tools/safeViews.ts +4 -2
  250. package/src/tools/taskLineage.ts +300 -0
  251. package/src/tools/toolCatalog.ts +6 -0
  252. package/src/tools/toolRegistry.ts +132 -0
  253. package/src/version.ts +2 -2
  254. package/ui/pages/audit.html +192 -3
  255. package/ui/pages/dashboard.html +1070 -14
  256. package/ui/pages/direct-sessions.html +505 -53
  257. package/ui/pages/task-detail.html +456 -438
  258. package/ui/pages/tasks.html +598 -61
@@ -0,0 +1,429 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createGoal, readGoalStatus } from "../../goal/goalStore.js";
7
+ import {
8
+ importSpecKitTasks,
9
+ parseSpecKitJson,
10
+ type SpecKitInput,
11
+ } from "../../goal/specKitImport.js";
12
+ import { PatchWardenError } from "../../errors.js";
13
+
14
+ // ── Helpers ───────────────────────────────────────────────────────
15
+
16
+ let tempDir: string;
17
+ let goalId: string;
18
+
19
+ beforeEach(() => {
20
+ tempDir = mkdtempSync(join(tmpdir(), "pw-speckit-"));
21
+ const result = createGoal("repo", "Spec Kit Test Goal", "desc", tempDir);
22
+ goalId = result.goal_id;
23
+ });
24
+
25
+ afterEach(() => {
26
+ rmSync(tempDir, { recursive: true, force: true });
27
+ });
28
+
29
+ function makeStandardInput(): SpecKitInput {
30
+ return {
31
+ spec: "feature-auth",
32
+ tasks: [
33
+ {
34
+ id: "T1",
35
+ desc: "Set up database schema",
36
+ files: ["src/db/schema.ts"],
37
+ },
38
+ {
39
+ id: "T2",
40
+ desc: "Implement auth service",
41
+ files: ["src/auth/service.ts", "src/auth/types.ts"],
42
+ },
43
+ {
44
+ id: "T3",
45
+ desc: "Write integration tests",
46
+ files: ["test/auth.test.ts"],
47
+ },
48
+ ],
49
+ };
50
+ }
51
+
52
+ // ── Tests ─────────────────────────────────────────────────────────
53
+
54
+ describe("specKitImport", () => {
55
+
56
+ describe("parseSpecKitJson", () => {
57
+ it("解析合法 JSON 返回 SpecKitInput", () => {
58
+ const json = JSON.stringify({
59
+ spec: "my-spec",
60
+ tasks: [
61
+ { id: "T1", desc: "Task one", files: ["a.ts"] },
62
+ { id: "T2", desc: "Task two", depends_on: ["T1"] },
63
+ ],
64
+ acceptance: ["T1 passes", "T2 passes"],
65
+ });
66
+ const input = parseSpecKitJson(json);
67
+ assert.equal(input.spec, "my-spec");
68
+ assert.equal(input.tasks.length, 2);
69
+ assert.equal(input.tasks[0].id, "T1");
70
+ assert.equal(input.tasks[0].desc, "Task one");
71
+ assert.deepEqual(input.tasks[0].files, ["a.ts"]);
72
+ assert.deepEqual(input.tasks[1].depends_on, ["T1"]);
73
+ assert.deepEqual(input.acceptance, ["T1 passes", "T2 passes"]);
74
+ });
75
+
76
+ it("缺 spec 字段抛出错误", () => {
77
+ const json = JSON.stringify({ tasks: [] });
78
+ assert.throws(
79
+ () => parseSpecKitJson(json),
80
+ (err: unknown) => {
81
+ assert.ok(err instanceof PatchWardenError);
82
+ assert.equal(err.reason, "invalid_spec_kit_input");
83
+ return true;
84
+ }
85
+ );
86
+ });
87
+
88
+ it("spec 为空字符串抛出错误", () => {
89
+ const json = JSON.stringify({ spec: "", tasks: [] });
90
+ assert.throws(
91
+ () => parseSpecKitJson(json),
92
+ (err: unknown) => {
93
+ assert.ok(err instanceof PatchWardenError);
94
+ assert.equal(err.reason, "invalid_spec_kit_input");
95
+ return true;
96
+ }
97
+ );
98
+ });
99
+
100
+ it("缺 tasks 字段抛出错误", () => {
101
+ const json = JSON.stringify({ spec: "my-spec" });
102
+ assert.throws(
103
+ () => parseSpecKitJson(json),
104
+ (err: unknown) => {
105
+ assert.ok(err instanceof PatchWardenError);
106
+ assert.equal(err.reason, "invalid_spec_kit_input");
107
+ return true;
108
+ }
109
+ );
110
+ });
111
+
112
+ it("tasks 不是数组抛出错误", () => {
113
+ const json = JSON.stringify({ spec: "my-spec", tasks: "not-an-array" });
114
+ assert.throws(
115
+ () => parseSpecKitJson(json),
116
+ (err: unknown) => {
117
+ assert.ok(err instanceof PatchWardenError);
118
+ assert.equal(err.reason, "invalid_spec_kit_input");
119
+ return true;
120
+ }
121
+ );
122
+ });
123
+
124
+ it("task 缺 id 抛出错误", () => {
125
+ const json = JSON.stringify({
126
+ spec: "my-spec",
127
+ tasks: [{ desc: "no id" }],
128
+ });
129
+ assert.throws(
130
+ () => parseSpecKitJson(json),
131
+ (err: unknown) => {
132
+ assert.ok(err instanceof PatchWardenError);
133
+ assert.equal(err.reason, "invalid_spec_kit_input");
134
+ return true;
135
+ }
136
+ );
137
+ });
138
+
139
+ it("无效 JSON 文本抛出错误", () => {
140
+ assert.throws(
141
+ () => parseSpecKitJson("{ not valid json"),
142
+ (err: unknown) => {
143
+ assert.ok(err instanceof PatchWardenError);
144
+ assert.equal(err.reason, "invalid_spec_kit_json");
145
+ return true;
146
+ }
147
+ );
148
+ });
149
+ });
150
+
151
+ describe("importSpecKitTasks — 标准导入", () => {
152
+ it("3 个无依赖 task → created_count=3, existing_count=0", () => {
153
+ const input = makeStandardInput();
154
+ const result = importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
155
+
156
+ assert.equal(result.created_count, 3);
157
+ assert.equal(result.existing_count, 0);
158
+ assert.equal(result.subgoal_ids.length, 3);
159
+ assert.equal(result.spec_name, "feature-auth");
160
+ assert.equal(result.goal_id, goalId);
161
+ });
162
+
163
+ it("每个 subgoal 的 title = task.desc", () => {
164
+ const input = makeStandardInput();
165
+ const result = importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
166
+
167
+ const status = readGoalStatus(goalId, tempDir);
168
+ assert.equal(status.subgoals.length, 3);
169
+ assert.equal(status.subgoals[0].title, "Set up database schema");
170
+ assert.equal(status.subgoals[1].title, "Implement auth service");
171
+ assert.equal(status.subgoals[2].title, "Write integration tests");
172
+ });
173
+
174
+ it("每个 subgoal 的 external_ref = task.id", () => {
175
+ const input = makeStandardInput();
176
+ importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
177
+
178
+ const status = readGoalStatus(goalId, tempDir);
179
+ assert.equal(status.subgoals[0].external_ref, "T1");
180
+ assert.equal(status.subgoals[1].external_ref, "T2");
181
+ assert.equal(status.subgoals[2].external_ref, "T3");
182
+ });
183
+
184
+ it("task.files 映射到 scope_hints", () => {
185
+ const input = makeStandardInput();
186
+ importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
187
+
188
+ const status = readGoalStatus(goalId, tempDir);
189
+ assert.deepEqual(status.subgoals[0].scope_hints, ["src/db/schema.ts"]);
190
+ assert.deepEqual(status.subgoals[1].scope_hints, [
191
+ "src/auth/service.ts",
192
+ "src/auth/types.ts",
193
+ ]);
194
+ assert.deepEqual(status.subgoals[2].scope_hints, ["test/auth.test.ts"]);
195
+ });
196
+
197
+ it("subgoal_ids 对应新创建的 subgoal id", () => {
198
+ const input = makeStandardInput();
199
+ const result = importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
200
+
201
+ assert.deepEqual(result.subgoal_ids, ["subgoal-001", "subgoal-002", "subgoal-003"]);
202
+ });
203
+ });
204
+
205
+ describe("importSpecKitTasks — 依赖关系导入", () => {
206
+ it("T2 depends_on T1 → T2 的 depends_on 含 T1 的 subgoal_id(非 external_ref)", () => {
207
+ const input: SpecKitInput = {
208
+ spec: "feature-deps",
209
+ tasks: [
210
+ { id: "T1", desc: "Foundation task" },
211
+ { id: "T2", desc: "Dependent task", depends_on: ["T1"] },
212
+ ],
213
+ };
214
+ const result = importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
215
+
216
+ assert.equal(result.created_count, 2);
217
+
218
+ const status = readGoalStatus(goalId, tempDir);
219
+ assert.equal(status.subgoals.length, 2);
220
+
221
+ const t1Subgoal = status.subgoals[0];
222
+ const t2Subgoal = status.subgoals[1];
223
+ assert.equal(t1Subgoal.external_ref, "T1");
224
+ assert.equal(t2Subgoal.external_ref, "T2");
225
+
226
+ // T2 depends_on 应包含 T1 的 subgoal_id,而非 external_ref
227
+ assert.ok(t2Subgoal.depends_on.includes(t1Subgoal.id));
228
+ assert.ok(!t2Subgoal.depends_on.includes("T1"));
229
+ });
230
+
231
+ it("多级依赖 T3 → T2 → T1 全部正确解析", () => {
232
+ const input: SpecKitInput = {
233
+ spec: "multi-level",
234
+ tasks: [
235
+ { id: "T1", desc: "Level 1" },
236
+ { id: "T2", desc: "Level 2", depends_on: ["T1"] },
237
+ { id: "T3", desc: "Level 3", depends_on: ["T2"] },
238
+ ],
239
+ };
240
+ importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
241
+
242
+ const status = readGoalStatus(goalId, tempDir);
243
+ assert.equal(status.subgoals.length, 3);
244
+ assert.deepEqual(status.subgoals[0].depends_on, []);
245
+ assert.deepEqual(status.subgoals[1].depends_on, ["subgoal-001"]);
246
+ assert.deepEqual(status.subgoals[2].depends_on, ["subgoal-002"]);
247
+ });
248
+
249
+ it("依赖不存在的 task → 忽略该依赖并继续创建", () => {
250
+ const input: SpecKitInput = {
251
+ spec: "missing-dep",
252
+ tasks: [
253
+ { id: "T1", desc: "Task with missing dep", depends_on: ["NONEXISTENT"] },
254
+ ],
255
+ };
256
+ const result = importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
257
+
258
+ assert.equal(result.created_count, 1);
259
+ const status = readGoalStatus(goalId, tempDir);
260
+ assert.equal(status.subgoals.length, 1);
261
+ assert.deepEqual(status.subgoals[0].depends_on, []);
262
+ });
263
+ });
264
+
265
+ describe("importSpecKitTasks — 验收标准映射", () => {
266
+ it("input.acceptance 写入 GoalStatus.acceptance_criteria", () => {
267
+ const input: SpecKitInput = {
268
+ spec: "feature-acceptance",
269
+ tasks: [{ id: "T1", desc: "Task one" }],
270
+ acceptance: ["T1 passes", "T2 passes"],
271
+ };
272
+ importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
273
+
274
+ const status = readGoalStatus(goalId, tempDir);
275
+ assert.deepEqual(status.acceptance_criteria, ["T1 passes", "T2 passes"]);
276
+ });
277
+
278
+ it("无 acceptance → GoalStatus.acceptance_criteria 不存在", () => {
279
+ const input: SpecKitInput = {
280
+ spec: "no-acceptance",
281
+ tasks: [{ id: "T1", desc: "Task one" }],
282
+ };
283
+ importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
284
+
285
+ const status = readGoalStatus(goalId, tempDir);
286
+ assert.equal(status.acceptance_criteria, undefined);
287
+ });
288
+
289
+ it("空 acceptance 数组 → GoalStatus.acceptance_criteria 不存在", () => {
290
+ const input: SpecKitInput = {
291
+ spec: "empty-acceptance",
292
+ tasks: [{ id: "T1", desc: "Task one" }],
293
+ acceptance: [],
294
+ };
295
+ importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
296
+
297
+ const status = readGoalStatus(goalId, tempDir);
298
+ assert.equal(status.acceptance_criteria, undefined);
299
+ });
300
+ });
301
+
302
+ describe("importSpecKitTasks — 幂等去重", () => {
303
+ it("重复导入相同 JSON → 第二次 created_count=0, existing_count=3", () => {
304
+ const input = makeStandardInput();
305
+
306
+ // 第一次导入
307
+ const result1 = importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
308
+ assert.equal(result1.created_count, 3);
309
+ assert.equal(result1.existing_count, 0);
310
+
311
+ // 第二次导入相同 JSON
312
+ const result2 = importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
313
+ assert.equal(result2.created_count, 0);
314
+ assert.equal(result2.existing_count, 3);
315
+ assert.deepEqual(result2.subgoal_ids, []);
316
+ });
317
+
318
+ it("重复导入后 subgoal 总数仍为 3", () => {
319
+ const input = makeStandardInput();
320
+ importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
321
+ importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
322
+
323
+ const status = readGoalStatus(goalId, tempDir);
324
+ assert.equal(status.subgoals.length, 3);
325
+ });
326
+
327
+ it("部分新 task 导入:只创建新增的", () => {
328
+ // 第一次导入 T1, T2
329
+ const input1: SpecKitInput = {
330
+ spec: "partial",
331
+ tasks: [
332
+ { id: "T1", desc: "Task one" },
333
+ { id: "T2", desc: "Task two" },
334
+ ],
335
+ };
336
+ const result1 = importSpecKitTasks(goalId, input1, { workspaceRoot: tempDir });
337
+ assert.equal(result1.created_count, 2);
338
+
339
+ // 第二次导入 T1, T2, T3(T1/T2 已存在)
340
+ const input2: SpecKitInput = {
341
+ spec: "partial",
342
+ tasks: [
343
+ { id: "T1", desc: "Task one" },
344
+ { id: "T2", desc: "Task two" },
345
+ { id: "T3", desc: "Task three" },
346
+ ],
347
+ };
348
+ const result2 = importSpecKitTasks(goalId, input2, { workspaceRoot: tempDir });
349
+ assert.equal(result2.created_count, 1);
350
+ assert.equal(result2.existing_count, 2);
351
+
352
+ const status = readGoalStatus(goalId, tempDir);
353
+ assert.equal(status.subgoals.length, 3);
354
+ });
355
+ });
356
+
357
+ describe("importSpecKitTasks — 无效输入", () => {
358
+ it("spec 为空 → 抛出错误且不创建 subgoal", () => {
359
+ const input = { spec: "", tasks: [] } as unknown as SpecKitInput;
360
+ assert.throws(
361
+ () => importSpecKitTasks(goalId, input, { workspaceRoot: tempDir }),
362
+ (err: unknown) => {
363
+ assert.ok(err instanceof PatchWardenError);
364
+ assert.equal(err.reason, "invalid_spec_kit_input");
365
+ return true;
366
+ }
367
+ );
368
+
369
+ const status = readGoalStatus(goalId, tempDir);
370
+ assert.equal(status.subgoals.length, 0);
371
+ });
372
+
373
+ it("tasks 不是数组 → 抛出错误且不创建 subgoal", () => {
374
+ const input = { spec: "test", tasks: "not-array" } as unknown as SpecKitInput;
375
+ assert.throws(
376
+ () => importSpecKitTasks(goalId, input, { workspaceRoot: tempDir }),
377
+ (err: unknown) => {
378
+ assert.ok(err instanceof PatchWardenError);
379
+ assert.equal(err.reason, "invalid_spec_kit_input");
380
+ return true;
381
+ }
382
+ );
383
+
384
+ const status = readGoalStatus(goalId, tempDir);
385
+ assert.equal(status.subgoals.length, 0);
386
+ });
387
+
388
+ it("tasks 缺失 → 抛出错误且不创建 subgoal", () => {
389
+ const input = { spec: "test" } as unknown as SpecKitInput;
390
+ assert.throws(
391
+ () => importSpecKitTasks(goalId, input, { workspaceRoot: tempDir }),
392
+ (err: unknown) => {
393
+ assert.ok(err instanceof PatchWardenError);
394
+ assert.equal(err.reason, "invalid_spec_kit_input");
395
+ return true;
396
+ }
397
+ );
398
+
399
+ const status = readGoalStatus(goalId, tempDir);
400
+ assert.equal(status.subgoals.length, 0);
401
+ });
402
+ });
403
+
404
+ describe("importSpecKitTasks — JSON 文本端到端", () => {
405
+ it("parseSpecKitJson + importSpecKitTasks 完整流程", () => {
406
+ const json = JSON.stringify({
407
+ spec: "e2e-spec",
408
+ tasks: [
409
+ { id: "T1", desc: "First task", files: ["a.ts"] },
410
+ { id: "T2", desc: "Second task", depends_on: ["T1"] },
411
+ ],
412
+ acceptance: ["All tests pass"],
413
+ });
414
+
415
+ const input = parseSpecKitJson(json);
416
+ const result = importSpecKitTasks(goalId, input, { workspaceRoot: tempDir });
417
+
418
+ assert.equal(result.created_count, 2);
419
+ assert.equal(result.spec_name, "e2e-spec");
420
+
421
+ const status = readGoalStatus(goalId, tempDir);
422
+ assert.equal(status.subgoals.length, 2);
423
+ assert.equal(status.subgoals[0].external_ref, "T1");
424
+ assert.equal(status.subgoals[1].external_ref, "T2");
425
+ assert.deepEqual(status.subgoals[1].depends_on, ["subgoal-001"]);
426
+ assert.deepEqual(status.acceptance_criteria, ["All tests pass"]);
427
+ });
428
+ });
429
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { reloadConfig } from "../../config.js";
7
+ import { waitForTask } from "../../tools/waitForTask.js";
8
+
9
+ let tempDir: string;
10
+ let tasksDir: string;
11
+ let prevConfigEnv: string | undefined;
12
+
13
+ function writeConfig(): void {
14
+ const configPath = join(tempDir, "patchwarden.config.json");
15
+ writeFileSync(
16
+ configPath,
17
+ JSON.stringify({
18
+ workspaceRoot: tempDir,
19
+ tasksDir: ".patchwarden/tasks",
20
+ plansDir: ".patchwarden/plans",
21
+ assessmentsDir: ".patchwarden/assessments",
22
+ directSessionsDir: ".patchwarden/direct-sessions",
23
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
24
+ allowedTestCommands: ["npm test"],
25
+ }),
26
+ "utf-8"
27
+ );
28
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
29
+ process.env.PATCHWARDEN_CONFIG = configPath;
30
+ reloadConfig();
31
+ }
32
+
33
+ function writeTaskStatus(taskId: string, status: Record<string, unknown>): string {
34
+ const taskDir = join(tasksDir, taskId);
35
+ mkdirSync(taskDir, { recursive: true });
36
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
37
+ return taskDir;
38
+ }
39
+
40
+ function baseStatus(taskId: string, overrides: Record<string, unknown> = {}): Record<string, unknown> {
41
+ return {
42
+ task_id: taskId,
43
+ plan_id: "plan-001",
44
+ agent: "codex",
45
+ workspace_root: tempDir,
46
+ repo_path: "repo",
47
+ resolved_repo_path: join(tempDir, "repo"),
48
+ created_at: "2026-07-12T10:00:00Z",
49
+ started_at: "2026-07-12T10:00:05Z",
50
+ finished_at: "2026-07-12T10:05:00Z",
51
+ updated_at: "2026-07-12T10:05:00Z",
52
+ timeout_seconds: 900,
53
+ error: null,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ describe("waitForTask", () => {
59
+ beforeEach(() => {
60
+ tempDir = mkdtempSync(join(tmpdir(), "pw-waitfortask-"));
61
+ tasksDir = join(tempDir, ".patchwarden", "tasks");
62
+ mkdirSync(tasksDir, { recursive: true });
63
+ writeConfig();
64
+ });
65
+
66
+ afterEach(() => {
67
+ if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
68
+ else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
69
+ reloadConfig();
70
+ rmSync(tempDir, { recursive: true, force: true });
71
+ });
72
+
73
+ // ── already terminal: done ──
74
+ it("returns immediately when task is already done", async () => {
75
+ writeTaskStatus("task-done-001", baseStatus("task-done-001", {
76
+ status: "done",
77
+ phase: "completed",
78
+ verify_status: "passed",
79
+ }));
80
+
81
+ const result = await waitForTask("task-done-001", 5);
82
+
83
+ assert.equal(result.task_id, "task-done-001");
84
+ assert.equal(result.status, "done");
85
+ assert.equal(result.terminal, true);
86
+ assert.equal(result.timed_out, false);
87
+ assert.equal(result.continuation_required, false);
88
+ // terminal → includes compact summary, next tool is audit_task
89
+ assert.ok(result.summary);
90
+ assert.equal(result.next_tool_call.name, "audit_task");
91
+ assert.equal(result.progress_summary, undefined);
92
+ });
93
+
94
+ // ── already terminal: failed ──
95
+ it("returns immediately when task is already failed", async () => {
96
+ writeTaskStatus("task-failed-001", baseStatus("task-failed-001", {
97
+ status: "failed",
98
+ phase: "failed",
99
+ error: "Agent exited with code 1",
100
+ }));
101
+
102
+ const result = await waitForTask("task-failed-001", 5);
103
+
104
+ assert.equal(result.terminal, true);
105
+ assert.equal(result.status, "failed");
106
+ assert.equal(result.timed_out, false);
107
+ assert.equal(result.continuation_required, false);
108
+ assert.ok(result.summary);
109
+ assert.equal(result.next_tool_call.name, "audit_task");
110
+ });
111
+
112
+ // ── running + timeout → continuation_required ──
113
+ it("returns continuation_required when running task times out", async () => {
114
+ writeTaskStatus("task-running-001", baseStatus("task-running-001", {
115
+ status: "running",
116
+ phase: "executing_agent",
117
+ finished_at: undefined,
118
+ }));
119
+
120
+ const result = await waitForTask("task-running-001", 1);
121
+
122
+ assert.equal(result.task_id, "task-running-001");
123
+ assert.equal(result.status, "running");
124
+ assert.equal(result.terminal, false);
125
+ assert.equal(result.timed_out, true);
126
+ assert.equal(result.continuation_required, true);
127
+ // non-terminal → includes progress_summary, next tool is wait_for_task
128
+ assert.ok(result.progress_summary);
129
+ assert.equal(result.next_tool_call.name, "wait_for_task");
130
+ assert.equal(result.summary, undefined);
131
+ // waited approximately 1 second
132
+ assert.ok(result.waited_ms >= 900 && result.waited_ms <= 3000);
133
+ });
134
+
135
+ // ── canceled (terminal) ──
136
+ it("returns terminal result for canceled task", async () => {
137
+ writeTaskStatus("task-canceled-001", baseStatus("task-canceled-001", {
138
+ status: "canceled",
139
+ phase: "canceled",
140
+ }));
141
+
142
+ const result = await waitForTask("task-canceled-001", 5);
143
+
144
+ assert.equal(result.terminal, true);
145
+ assert.equal(result.status, "canceled");
146
+ assert.equal(result.timed_out, false);
147
+ assert.equal(result.continuation_required, false);
148
+ assert.ok(result.summary);
149
+ assert.equal(result.next_tool_call.name, "audit_task");
150
+ });
151
+
152
+ // ── task not found ──
153
+ it("throws when task does not exist", async () => {
154
+ await assert.rejects(
155
+ () => waitForTask("non-existent-task", 1),
156
+ /(File not found|Task not found)/
157
+ );
158
+ });
159
+
160
+ // ── invalid wait_seconds ──
161
+ it("throws when wait_seconds is out of range", async () => {
162
+ writeTaskStatus("task-validate-001", baseStatus("task-validate-001", {
163
+ status: "done",
164
+ phase: "completed",
165
+ }));
166
+
167
+ await assert.rejects(
168
+ () => waitForTask("task-validate-001", 0),
169
+ /wait_seconds must be an integer from 1 to 30/
170
+ );
171
+ await assert.rejects(
172
+ () => waitForTask("task-validate-001", 31),
173
+ /wait_seconds must be an integer from 1 to 30/
174
+ );
175
+ });
176
+ });