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,173 @@
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 { getTaskSummary } from "../../tools/getTaskSummary.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("getTaskSummary", () => {
59
+ beforeEach(() => {
60
+ tempDir = mkdtempSync(join(tmpdir(), "pw-getsummary-"));
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
+ // ── compact mode ──
74
+ it("returns compact summary in compact mode", () => {
75
+ writeTaskStatus("task-compact-001", baseStatus("task-compact-001", {
76
+ status: "done",
77
+ phase: "completed",
78
+ verify_status: "passed",
79
+ }));
80
+
81
+ const result = getTaskSummary("task-compact-001", { view: "compact" });
82
+
83
+ assert.equal(result.view, "compact");
84
+ assert.equal(result.task_id, "task-compact-001");
85
+ assert.equal(result.status, "done");
86
+ assert.equal(result.terminal, true);
87
+ // compact-only fields
88
+ assert.equal(typeof result.changed_files_total, "number");
89
+ assert.equal(typeof result.release_artifacts_count, "number");
90
+ assert.ok(result.artifact_hygiene);
91
+ assert.ok("max_items" in result.artifact_hygiene);
92
+ });
93
+
94
+ // ── full / standard mode ──
95
+ it("returns full summary in standard mode", () => {
96
+ writeTaskStatus("task-full-001", baseStatus("task-full-001", {
97
+ status: "done",
98
+ phase: "completed",
99
+ verify_status: "passed",
100
+ }));
101
+
102
+ const result = getTaskSummary("task-full-001");
103
+
104
+ // standard mode has no `view` field
105
+ assert.equal((result as unknown as { view?: string }).view, undefined);
106
+ assert.equal(result.task_id, "task-full-001");
107
+ assert.equal(result.status, "done");
108
+ assert.equal(result.terminal, true);
109
+ // full-mode-only fields
110
+ assert.ok("log_tails" in result);
111
+ assert.ok("artifacts" in result);
112
+ assert.ok("failed_command_detail" in result);
113
+ assert.ok("acceptance_reviewed_at" in result);
114
+ });
115
+
116
+ // ── task not found ──
117
+ it("throws when task does not exist", () => {
118
+ assert.throws(
119
+ () => getTaskSummary("non-existent-task"),
120
+ /(File not found|Task not found)/
121
+ );
122
+ });
123
+
124
+ // ── terminal: done ──
125
+ it("returns terminal summary for done task with passed verification", () => {
126
+ writeTaskStatus("task-done-001", baseStatus("task-done-001", {
127
+ status: "done",
128
+ phase: "completed",
129
+ verify_status: "passed",
130
+ }));
131
+
132
+ const result = getTaskSummary("task-done-001");
133
+
134
+ assert.equal(result.terminal, true);
135
+ assert.equal(result.status, "done");
136
+ assert.equal(result.verify_status, "passed");
137
+ // done + verify passed → ready_for_review
138
+ assert.equal(result.acceptance_status, "ready_for_review");
139
+ });
140
+
141
+ // ── terminal: failed ──
142
+ it("returns terminal summary for failed task", () => {
143
+ writeTaskStatus("task-failed-001", baseStatus("task-failed-001", {
144
+ status: "failed",
145
+ phase: "failed",
146
+ error: "Agent exited with code 1",
147
+ }));
148
+
149
+ const result = getTaskSummary("task-failed-001");
150
+
151
+ assert.equal(result.terminal, true);
152
+ assert.equal(result.status, "failed");
153
+ assert.equal(result.acceptance_status, "failed");
154
+ assert.ok(result.errors.includes("Agent exited with code 1"));
155
+ });
156
+
157
+ // ── running (non-terminal) ──
158
+ it("returns non-terminal summary for running task", () => {
159
+ writeTaskStatus("task-running-001", baseStatus("task-running-001", {
160
+ status: "running",
161
+ phase: "executing_agent",
162
+ started_at: "2026-07-12T10:00:00Z",
163
+ finished_at: undefined,
164
+ }));
165
+
166
+ const result = getTaskSummary("task-running-001");
167
+
168
+ assert.equal(result.terminal, false);
169
+ assert.equal(result.status, "running");
170
+ assert.equal(result.phase, "executing_agent");
171
+ assert.equal(result.acceptance_status, "pending");
172
+ });
173
+ });
@@ -0,0 +1,189 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { reloadConfig } from "../../config.js";
7
+ import { exportGoalReport } from "../../goal/goalReport.js";
8
+ import { getGoalDir, writeGoalStatus } from "../../goal/goalStore.js";
9
+ import type { GoalStatus, Subgoal } from "../../goal/goalStatus.js";
10
+
11
+ // ── Helpers ───────────────────────────────────────────────────────
12
+
13
+ function makeSubgoal(overrides: Partial<Subgoal> & { id: string }): Subgoal {
14
+ return {
15
+ title: "Subgoal " + overrides.id,
16
+ status: "ready",
17
+ depends_on: [],
18
+ task_ids: [],
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ function makeGoalStatus(subgoals: Subgoal[], overrides: Partial<GoalStatus> = {}): GoalStatus {
24
+ const now = "2026-07-10T00:00:00.000Z";
25
+ return {
26
+ goal_id: "goal_test_001",
27
+ title: "Test Goal",
28
+ status: "active",
29
+ repo_path: "/repo/test",
30
+ created_at: now,
31
+ updated_at: now,
32
+ subgoals,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ let tempDir: string;
38
+ let prevConfigEnv: string | undefined;
39
+
40
+ function writeConfig(): void {
41
+ const configPath = join(tempDir, "patchwarden.config.json");
42
+ writeFileSync(
43
+ configPath,
44
+ JSON.stringify({
45
+ workspaceRoot: tempDir,
46
+ tasksDir: ".patchwarden/tasks",
47
+ plansDir: ".patchwarden/plans",
48
+ assessmentsDir: ".patchwarden/assessments",
49
+ agents: {
50
+ codex: { command: "codex", args: [] },
51
+ opencode: { command: "opencode", args: [] },
52
+ },
53
+ allowedTestCommands: ["npm test", "npm run build"],
54
+ defaultTaskTimeoutSeconds: 30,
55
+ maxTaskTimeoutSeconds: 120,
56
+ }),
57
+ "utf-8"
58
+ );
59
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
60
+ process.env.PATCHWARDEN_CONFIG = configPath;
61
+ reloadConfig();
62
+ }
63
+
64
+ /**
65
+ * 创建 goal 目录并写入 goal_status.json(writeGoalStatus 不自动创建目录)。
66
+ */
67
+ function seedGoalStatus(goalId: string, status: GoalStatus, workspaceRoot: string): void {
68
+ mkdirSync(getGoalDir(goalId, workspaceRoot), { recursive: true });
69
+ writeGoalStatus(goalId, status, workspaceRoot);
70
+ }
71
+
72
+ // ── Tests ─────────────────────────────────────────────────────────
73
+
74
+ describe("goalReport", () => {
75
+ beforeEach(() => {
76
+ tempDir = mkdtempSync(join(tmpdir(), "pw-goal-report-"));
77
+ writeConfig();
78
+ });
79
+
80
+ afterEach(() => {
81
+ if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
82
+ else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
83
+ reloadConfig();
84
+ rmSync(tempDir, { recursive: true, force: true });
85
+ });
86
+
87
+ it("完成 Goal 报告:所有 subgoal accepted,completion_rate=100", () => {
88
+ const goal = makeGoalStatus([
89
+ makeSubgoal({
90
+ id: "subgoal-001",
91
+ title: "Feature A",
92
+ status: "accepted",
93
+ accepted_at: "2026-07-01T00:00:00.000Z",
94
+ }),
95
+ makeSubgoal({
96
+ id: "subgoal-002",
97
+ title: "Feature B",
98
+ status: "accepted",
99
+ accepted_at: "2026-07-02T00:00:00.000Z",
100
+ }),
101
+ ], { goal_id: "goal_done_001" });
102
+ seedGoalStatus("goal_done_001", goal, tempDir);
103
+
104
+ const report = exportGoalReport("goal_done_001", { workspaceRoot: tempDir });
105
+
106
+ assert.equal(report.completion_rate, 100);
107
+ assert.equal(report.goal_id, "goal_done_001");
108
+ assert.equal(report.subgoals.length, 2);
109
+ assert.equal(report.bounded, true);
110
+ assert.ok(existsSync(report.files.report_md));
111
+ assert.ok(existsSync(report.files.report_json));
112
+ });
113
+
114
+ it("未完成 Goal 报告:有 running/ready subgoal,报告含 '未完成'", () => {
115
+ const goal = makeGoalStatus([
116
+ makeSubgoal({
117
+ id: "subgoal-001",
118
+ title: "Done",
119
+ status: "accepted",
120
+ accepted_at: "2026-07-01T00:00:00.000Z",
121
+ }),
122
+ makeSubgoal({ id: "subgoal-002", title: "WIP", status: "running" }),
123
+ ]);
124
+ seedGoalStatus("goal_wip_001", goal, tempDir);
125
+
126
+ const report = exportGoalReport("goal_wip_001", { workspaceRoot: tempDir });
127
+
128
+ const md = readFileSync(report.files.report_md, "utf-8");
129
+ assert.ok(md.includes("未完成"), "REPORT.md should contain '未完成' annotation");
130
+ assert.ok(
131
+ report.risks.some((r) => r.includes("未完成")),
132
+ "risks array should contain '未完成' entry"
133
+ );
134
+ });
135
+
136
+ it("空 Goal 报告:无 subgoal,completion_rate=0,报告含 '无子目标'", () => {
137
+ const goal = makeGoalStatus([]);
138
+ seedGoalStatus("goal_empty_001", goal, tempDir);
139
+
140
+ const report = exportGoalReport("goal_empty_001", { workspaceRoot: tempDir });
141
+
142
+ assert.equal(report.completion_rate, 0);
143
+ const md = readFileSync(report.files.report_md, "utf-8");
144
+ assert.ok(md.includes("无子目标"), "REPORT.md should contain '无子目标' annotation");
145
+ });
146
+
147
+ it("双格式输出:REPORT.md 和 report.json 同时存在", () => {
148
+ const goal = makeGoalStatus([
149
+ makeSubgoal({
150
+ id: "subgoal-001",
151
+ title: "Task A",
152
+ status: "accepted",
153
+ accepted_at: "2026-07-01T00:00:00.000Z",
154
+ }),
155
+ ]);
156
+ seedGoalStatus("goal_dual_001", goal, tempDir);
157
+
158
+ const report = exportGoalReport("goal_dual_001", { workspaceRoot: tempDir });
159
+
160
+ const reportDir = resolve(tempDir, ".patchwarden", "goals", "goal_dual_001", "report");
161
+ const mdPath = join(reportDir, "REPORT.md");
162
+ const jsonPath = join(reportDir, "report.json");
163
+ assert.ok(existsSync(mdPath), "REPORT.md should exist");
164
+ assert.ok(existsSync(jsonPath), "report.json should exist");
165
+ assert.equal(report.files.report_md, mdPath);
166
+ assert.equal(report.files.report_json, jsonPath);
167
+ });
168
+
169
+ it("脱敏验证:subgoal title 含敏感信息,输出中不含原始值", () => {
170
+ const goal = makeGoalStatus([
171
+ makeSubgoal({
172
+ id: "subgoal-001",
173
+ title: "Deploy with token=abcd1234",
174
+ status: "accepted",
175
+ accepted_at: "2026-07-01T00:00:00.000Z",
176
+ }),
177
+ ]);
178
+ seedGoalStatus("goal_redact_001", goal, tempDir);
179
+
180
+ const report = exportGoalReport("goal_redact_001", { workspaceRoot: tempDir });
181
+
182
+ const md = readFileSync(report.files.report_md, "utf-8");
183
+ const json = readFileSync(report.files.report_json, "utf-8");
184
+ assert.ok(!md.includes("abcd1234"), "REPORT.md must not contain raw secret value");
185
+ assert.ok(!json.includes("abcd1234"), "report.json must not contain raw secret value");
186
+ assert.ok(md.includes("[REDACTED]"), "REPORT.md should contain redaction marker");
187
+ assert.ok(json.includes("[REDACTED]"), "report.json should contain redaction marker");
188
+ });
189
+ });
@@ -14,8 +14,8 @@ import { PatchWardenError } from "../../errors.js";
14
14
  describe("createSubgoalTask", () => {
15
15
 
16
16
  describe("错误路径", () => {
17
- it("execution_mode=assess_only 抛 invalid_execution_mode", () => {
18
- assert.throws(
17
+ it("execution_mode=assess_only 抛 invalid_execution_mode", async () => {
18
+ await assert.rejects(
19
19
  () =>
20
20
  createSubgoalTask({
21
21
  goal_id: "goal_nonexistent_test",
@@ -32,8 +32,8 @@ describe("createSubgoalTask", () => {
32
32
  );
33
33
  });
34
34
 
35
- it("goal_id 不存在抛 goal_not_found", () => {
36
- assert.throws(
35
+ it("goal_id 不存在抛 goal_not_found", async () => {
36
+ await assert.rejects(
37
37
  () =>
38
38
  createSubgoalTask({
39
39
  goal_id: "goal_definitely_does_not_exist_99999",
@@ -50,10 +50,10 @@ describe("createSubgoalTask", () => {
50
50
  );
51
51
  });
52
52
 
53
- it("assess_only 优先于 goal_not_found(在读取 goal 前校验)", () => {
53
+ it("assess_only 优先于 goal_not_found(在读取 goal 前校验)", async () => {
54
54
  // execution_mode=assess_only 应先抛 invalid_execution_mode,
55
55
  // 而不是去读不存在的 goal
56
- assert.throws(
56
+ await assert.rejects(
57
57
  () =>
58
58
  createSubgoalTask({
59
59
  goal_id: "goal_definitely_does_not_exist_99999",
@@ -7,7 +7,7 @@ import { getToolDefs, handleToolCall } from "../../tools/registry.js";
7
7
  import { reloadConfig } from "../../config.js";
8
8
  import { PatchWardenError } from "../../errors.js";
9
9
 
10
- // ── 9 个 Goal Session MCP 工具名 ──────────────────────────────────
10
+ // ── 11 个 Goal Session MCP 工具名 ─────────────────────────────────
11
11
 
12
12
  const GOAL_TOOL_NAMES = [
13
13
  "create_goal",
@@ -19,6 +19,8 @@ const GOAL_TOOL_NAMES = [
19
19
  "suggest_next_subgoal",
20
20
  "summarize_goal_progress",
21
21
  "export_handoff",
22
+ "export_goal_report",
23
+ "import_speckit_tasks",
22
24
  ] as const;
23
25
 
24
26
  // ── 测试环境隔离:使用临时 workspaceRoot + full profile ───────────
@@ -67,7 +69,7 @@ afterEach(() => {
67
69
  describe("goal tools registry", () => {
68
70
 
69
71
  describe("getToolDefs — 工具定义", () => {
70
- it("包含全部 9 个 goal 工具", () => {
72
+ it("包含全部 11 个 goal 工具", () => {
71
73
  const tools = getToolDefs();
72
74
  const names = new Set(tools.map((t) => t.name));
73
75
  for (const name of GOAL_TOOL_NAMES) {
@@ -75,7 +77,7 @@ describe("goal tools registry", () => {
75
77
  }
76
78
  });
77
79
 
78
- it("每个 goal 工具描述以 v0.8.0 开头且 inputSchema.type 为 object", () => {
80
+ it("每个 goal 工具描述非空且 inputSchema.type 为 object", () => {
79
81
  const tools = getToolDefs();
80
82
  const byName = new Map(tools.map((t) => [t.name, t]));
81
83
  for (const name of GOAL_TOOL_NAMES) {
@@ -83,8 +85,8 @@ describe("goal tools registry", () => {
83
85
  assert.ok(tool, `Tool not found: ${name}`);
84
86
  assert.equal(tool.inputSchema.type, "object", `${name} schema type should be object`);
85
87
  assert.ok(
86
- tool.description.startsWith("v0.8.0:"),
87
- `${name} description should start with v0.8.0:`
88
+ typeof tool.description === "string" && tool.description.length > 0,
89
+ `${name} description should be a non-empty string`
88
90
  );
89
91
  }
90
92
  });
@@ -96,6 +96,36 @@ describe("guardPath", () => {
96
96
  rmSync(target, { recursive: true, force: true });
97
97
  }
98
98
  });
99
+
100
+ it("rejects Windows junction escape", { skip: process.platform !== "win32" ? "Windows-only test" : undefined }, () => {
101
+ const target = mkdtempSync(join(tmpdir(), "pw-junction-target-"));
102
+ try {
103
+ const linkPath = join(tempDir, "escape-junction");
104
+ symlinkSync(target, linkPath, "junction");
105
+ assert.throws(
106
+ () => guardPath("escape-junction/secret.txt", tempDir),
107
+ PatchWardenError
108
+ );
109
+ } finally {
110
+ rmSync(target, { recursive: true, force: true });
111
+ }
112
+ });
113
+
114
+ it("rejects intermediate symlink escape (TOCTOU defense)", { skip: process.platform === "win32" ? "Windows symlink permissions unstable" : undefined }, () => {
115
+ const target = mkdtempSync(join(tmpdir(), "pw-toctou-target-"));
116
+ try {
117
+ const linkPath = join(tempDir, "escape-link");
118
+ symlinkSync(target, linkPath);
119
+ mkdirSync(join(target, "subdir"), { recursive: true });
120
+ writeFileSync(join(target, "subdir", "secret.txt"), "secret", "utf-8");
121
+ assert.throws(
122
+ () => guardPath("escape-link/subdir/secret.txt", tempDir),
123
+ PatchWardenError
124
+ );
125
+ } finally {
126
+ rmSync(target, { recursive: true, force: true });
127
+ }
128
+ });
99
129
  });
100
130
 
101
131
  describe("guardWorkspacePath", () => {
@@ -0,0 +1,156 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { reloadConfig } from "../../config.js";
7
+ import { getProjectPolicySummary } from "../../policy/projectPolicy.js";
8
+ import { releaseCleanup, releasePrepare, releaseVerify } from "../../tools/releaseMode.js";
9
+
10
+ let tempDir: string;
11
+ let repoPath: string;
12
+ let prevConfigEnv: string | undefined;
13
+
14
+ function writeConfig(allowedTestCommands = ["npm test", "npm run build"]): void {
15
+ const configPath = join(tempDir, "patchwarden.config.json");
16
+ writeFileSync(
17
+ configPath,
18
+ JSON.stringify({
19
+ workspaceRoot: tempDir,
20
+ plansDir: ".patchwarden/plans",
21
+ tasksDir: ".patchwarden/tasks",
22
+ assessmentsDir: ".patchwarden/assessments",
23
+ allowedTestCommands,
24
+ agents: {},
25
+ defaultTaskTimeoutSeconds: 30,
26
+ maxTaskTimeoutSeconds: 120,
27
+ }),
28
+ "utf-8",
29
+ );
30
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
31
+ process.env.PATCHWARDEN_CONFIG = configPath;
32
+ reloadConfig(configPath);
33
+ }
34
+
35
+ function writePackage(version = "1.3.0"): void {
36
+ writeFileSync(
37
+ join(repoPath, "package.json"),
38
+ JSON.stringify({
39
+ name: "patchwarden-test",
40
+ version,
41
+ repository: { type: "git", url: "git+https://github.com/example/patchwarden-test.git" },
42
+ }),
43
+ "utf-8",
44
+ );
45
+ }
46
+
47
+ describe("project policy and release mode", () => {
48
+ beforeEach(() => {
49
+ tempDir = mkdtempSync(join(tmpdir(), "pw-policy-"));
50
+ repoPath = join(tempDir, "repo");
51
+ mkdirSync(repoPath, { recursive: true });
52
+ writePackage();
53
+ writeConfig();
54
+ });
55
+
56
+ afterEach(() => {
57
+ if (prevConfigEnv === undefined) {
58
+ delete process.env.PATCHWARDEN_CONFIG;
59
+ } else {
60
+ process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
61
+ }
62
+ reloadConfig();
63
+ rmSync(tempDir, { recursive: true, force: true });
64
+ });
65
+
66
+ it("returns safe defaults when project-policy.json is missing", () => {
67
+ const summary = getProjectPolicySummary(repoPath);
68
+ assert.equal(summary.exists, false);
69
+ assert.equal(summary.valid, true);
70
+ assert.equal(summary.effective_policy.release_mode.version_source, "package.json");
71
+ assert.equal(summary.release_readiness.version, "1.3.0");
72
+ });
73
+
74
+ it("parses BOM-prefixed policy JSON and reports allowlist issues without granting permission", () => {
75
+ mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
76
+ writeFileSync(
77
+ join(repoPath, ".patchwarden", "project-policy.json"),
78
+ "\uFEFF" + JSON.stringify({
79
+ allowed_commands: ["npm run build", "npm publish"],
80
+ release_mode: {
81
+ version_source: "package.json",
82
+ required_commands: ["npm run build", "not-allowed"],
83
+ },
84
+ }),
85
+ "utf-8",
86
+ );
87
+ const summary = getProjectPolicySummary(repoPath);
88
+ assert.equal(summary.exists, true);
89
+ assert.equal(summary.valid, false);
90
+ assert.ok(summary.issues.some((issue) => issue.code === "high_risk_command"));
91
+ assert.ok(summary.issues.some((issue) => issue.code === "command_not_allowlisted"));
92
+ });
93
+
94
+ it("rejects unsafe path patterns and sensitive protected paths block cleanup", () => {
95
+ mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
96
+ writeFileSync(
97
+ join(repoPath, ".patchwarden", "project-policy.json"),
98
+ JSON.stringify({
99
+ auto_cleanup: { enabled: true, patterns: ["../escape", ".git", "release_packages"], exclude: [] },
100
+ protected_paths: [".env", "release_packages"],
101
+ }),
102
+ "utf-8",
103
+ );
104
+ mkdirSync(join(repoPath, "release_packages"), { recursive: true });
105
+ const summary = getProjectPolicySummary(repoPath);
106
+ assert.equal(summary.valid, false);
107
+ assert.ok(summary.issues.some((issue) => issue.code === "unsafe_path_pattern"));
108
+ const cleanup = releaseCleanup({ repo_path: repoPath, dry_run: true });
109
+ const skipped = cleanup.summary.skipped as Array<{ path: string; reason: string }>;
110
+ assert.ok(skipped.some((entry) => entry.path === "release_packages" && entry.reason === "protected_or_sensitive"));
111
+ });
112
+
113
+ it("release_cleanup defaults to dry run and writes BOM-free JSON summary", () => {
114
+ mkdirSync(join(repoPath, "release_packages"), { recursive: true });
115
+ const result = releaseCleanup({ repo_path: repoPath });
116
+ assert.equal(result.mode, "release_cleanup");
117
+ assert.equal((result.summary as any).dry_run, true);
118
+ assert.equal(existsSync(join(repoPath, "release_packages")), true);
119
+ const reportPath = join(repoPath, String((result.summary as any).report_path));
120
+ const raw = readFileSync(reportPath, "utf-8");
121
+ assert.notEqual(raw.charCodeAt(0), 0xfeff);
122
+ assert.doesNotThrow(() => JSON.parse(raw));
123
+ });
124
+
125
+ it("release_cleanup honors auto_cleanup.enabled=false before deleting", () => {
126
+ mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
127
+ writeFileSync(
128
+ join(repoPath, ".patchwarden", "project-policy.json"),
129
+ JSON.stringify({
130
+ auto_cleanup: { enabled: false, patterns: ["release_packages"], exclude: [] },
131
+ }),
132
+ "utf-8",
133
+ );
134
+ mkdirSync(join(repoPath, "release_packages"), { recursive: true });
135
+ const result = releaseCleanup({ repo_path: repoPath, dry_run: false });
136
+ assert.equal((result.summary as any).cleanup_disabled_by_policy, true);
137
+ assert.deepEqual((result.summary as any).removed, []);
138
+ assert.equal(existsSync(join(repoPath, "release_packages")), true);
139
+ });
140
+
141
+ it("release_verify fails when required metadata cannot be inferred", async () => {
142
+ writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "", version: "" }), "utf-8");
143
+ const result = await releaseVerify({ repo_path: repoPath });
144
+ assert.equal(result.ok, false);
145
+ const stages = result.summary.stages as Record<string, string>;
146
+ assert.equal(stages.published_verified, "failed");
147
+ assert.equal(stages.github_release_verified, "failed");
148
+ assert.equal(stages.ci_verified, "failed");
149
+ });
150
+
151
+ it("release_prepare blocks commands not accepted by the existing command guard", () => {
152
+ const result = releasePrepare({ repo_path: repoPath, required_commands: ["npm run build", "npm run secret"] });
153
+ const commands = result.summary.commands as Array<{ command: string; status: string }>;
154
+ assert.equal(commands.find((entry) => entry.command === "npm run secret")?.status, "blocked");
155
+ });
156
+ });