patchwarden 1.5.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 (217) hide show
  1. package/README.en.md +43 -2
  2. package/README.md +43 -2
  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 -2197
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/doctor.d.ts +18 -1
  44. package/dist/doctor.js +579 -348
  45. package/dist/goal/goalReport.d.ts +54 -0
  46. package/dist/goal/goalReport.js +204 -0
  47. package/dist/goal/goalStatus.d.ts +6 -0
  48. package/dist/goal/specKitImport.d.ts +63 -0
  49. package/dist/goal/specKitImport.js +220 -0
  50. package/dist/goal/subgoalSync.js +2 -1
  51. package/dist/httpServer.js +15 -12
  52. package/dist/index.js +7 -4
  53. package/dist/logging.d.ts +7 -1
  54. package/dist/logging.js +8 -0
  55. package/dist/runner/changeCapture.d.ts +3 -3
  56. package/dist/runner/changeCapture.js +63 -39
  57. package/dist/runner/cli.js +7 -6
  58. package/dist/runner/postTaskCleanup.js +26 -2
  59. package/dist/runner/runTask.js +245 -221
  60. package/dist/runner/simpleProcess.js +4 -4
  61. package/dist/runner/watch.js +17 -14
  62. package/dist/security/contentRedaction.d.ts +6 -0
  63. package/dist/security/contentRedaction.js +22 -0
  64. package/dist/smoke-test.js +257 -251
  65. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  66. package/dist/test/unit/apply-patch.test.js +225 -0
  67. package/dist/test/unit/create-task.test.d.ts +1 -0
  68. package/dist/test/unit/create-task.test.js +197 -0
  69. package/dist/test/unit/direct-guards.test.js +124 -9
  70. package/dist/test/unit/evidence-pack.test.js +95 -1
  71. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  72. package/dist/test/unit/get-task-status.test.js +174 -0
  73. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  74. package/dist/test/unit/get-task-summary.test.js +146 -0
  75. package/dist/test/unit/goal-report.test.d.ts +1 -0
  76. package/dist/test/unit/goal-report.test.js +159 -0
  77. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  78. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  79. package/dist/test/unit/path-guard.test.js +24 -0
  80. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  81. package/dist/test/unit/spec-kit-import.test.js +341 -0
  82. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  83. package/dist/test/unit/wait-for-task.test.js +144 -0
  84. package/dist/tools/auditTask.d.ts +8 -63
  85. package/dist/tools/auditTask.js +12 -8
  86. package/dist/tools/createDirectSession.d.ts +1 -1
  87. package/dist/tools/createDirectSession.js +2 -2
  88. package/dist/tools/createTask.d.ts +2 -2
  89. package/dist/tools/createTask.js +4 -4
  90. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  91. package/dist/tools/dispatch/coreDispatch.js +282 -0
  92. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  93. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  94. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  95. package/dist/tools/dispatch/directDispatch.js +115 -0
  96. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  97. package/dist/tools/dispatch/goalDispatch.js +91 -0
  98. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  99. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  100. package/dist/tools/dispatch/types.d.ts +23 -0
  101. package/dist/tools/dispatch/types.js +15 -0
  102. package/dist/tools/evidencePack.d.ts +6 -0
  103. package/dist/tools/evidencePack.js +213 -6
  104. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  105. package/dist/tools/finalizeDirectSession.js +3 -3
  106. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  107. package/dist/tools/goalSubgoalTask.js +2 -2
  108. package/dist/tools/healthCheck.js +3 -3
  109. package/dist/tools/registry.d.ts +3 -3
  110. package/dist/tools/registry.js +60 -503
  111. package/dist/tools/retryTask.d.ts +2 -2
  112. package/dist/tools/retryTask.js +2 -2
  113. package/dist/tools/runTaskLoop.js +4 -4
  114. package/dist/tools/safeViews.d.ts +2 -2
  115. package/dist/tools/safeViews.js +2 -2
  116. package/dist/tools/toolRegistry.js +22 -0
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/docs/CODE_WIKI.md +983 -0
  120. package/docs/agentseal-integration.md +150 -0
  121. package/docs/architecture.md +63 -0
  122. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  123. package/docs/dashboard-overview.md +86 -0
  124. package/docs/demo.md +8 -0
  125. package/docs/direct-session-workflow.md +98 -0
  126. package/docs/evidence-pack-schema.md +215 -0
  127. package/docs/execution-plan-2026-07-09.md +315 -0
  128. package/docs/lineage-evidence-pack-workflow.md +127 -0
  129. package/docs/mcp-inspector-testing.md +200 -0
  130. package/docs/open-source-application.md +162 -0
  131. package/docs/opencode-worker.md +151 -0
  132. package/docs/openhands-worker.md +181 -0
  133. package/docs/release-evidence.md +72 -0
  134. package/docs/roadmap-execution-and-acceptance.md +365 -0
  135. package/docs/spec-kit-integration.md +131 -0
  136. package/docs/task-safe-review-workflow.md +98 -0
  137. package/docs/threat-model.md +79 -0
  138. package/docs/user-feedback.md +40 -0
  139. package/docs/why-patchwarden.md +110 -0
  140. package/package.json +2 -2
  141. package/scripts/checks/control-center-smoke.js +356 -0
  142. package/scripts/checks/lifecycle-smoke.js +29 -23
  143. package/scripts/checks/mcp-smoke.js +3 -1
  144. package/scripts/generate-demo-gif.py +320 -0
  145. package/src/assessments/agentAssessor.ts +3 -3
  146. package/src/assessments/assessmentStore.ts +2 -2
  147. package/src/assessments/confirmCli.ts +5 -4
  148. package/src/config.ts +37 -0
  149. package/src/control/middleware/auth.ts +17 -0
  150. package/src/control/middleware/static.ts +71 -0
  151. package/src/control/routes/audit.ts +321 -0
  152. package/src/control/routes/evidence.ts +107 -0
  153. package/src/control/routes/lineage.ts +92 -0
  154. package/src/control/routes/policy.ts +81 -0
  155. package/src/control/routes/process.ts +204 -0
  156. package/src/control/routes/sessions.ts +251 -0
  157. package/src/control/routes/status.ts +325 -0
  158. package/src/control/routes/taskActions.ts +248 -0
  159. package/src/control/routes/tasks.ts +323 -0
  160. package/src/control/routes/workspace.ts +203 -0
  161. package/src/control/runtime.ts +472 -0
  162. package/src/control/server.ts +471 -0
  163. package/src/control/shared.ts +294 -0
  164. package/src/controlCenter.ts +7 -2347
  165. package/src/direct/directGuards.ts +28 -7
  166. package/src/doctor.ts +741 -481
  167. package/src/goal/goalReport.ts +271 -0
  168. package/src/goal/goalStatus.ts +6 -0
  169. package/src/goal/specKitImport.ts +355 -0
  170. package/src/goal/subgoalSync.ts +4 -2
  171. package/src/httpServer.ts +17 -14
  172. package/src/index.ts +7 -4
  173. package/src/logging.ts +10 -1
  174. package/src/runner/changeCapture.ts +70 -42
  175. package/src/runner/cli.ts +7 -6
  176. package/src/runner/postTaskCleanup.ts +26 -2
  177. package/src/runner/runTask.ts +325 -223
  178. package/src/runner/simpleProcess.ts +4 -4
  179. package/src/runner/watch.ts +17 -14
  180. package/src/security/contentRedaction.ts +29 -0
  181. package/src/smoke-test.ts +252 -250
  182. package/src/test/unit/apply-patch.test.ts +293 -0
  183. package/src/test/unit/create-task.test.ts +255 -0
  184. package/src/test/unit/direct-guards.test.ts +178 -8
  185. package/src/test/unit/evidence-pack.test.ts +110 -1
  186. package/src/test/unit/get-task-status.test.ts +203 -0
  187. package/src/test/unit/get-task-summary.test.ts +173 -0
  188. package/src/test/unit/goal-report.test.ts +189 -0
  189. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  190. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  191. package/src/test/unit/path-guard.test.ts +30 -0
  192. package/src/test/unit/spec-kit-import.test.ts +429 -0
  193. package/src/test/unit/wait-for-task.test.ts +176 -0
  194. package/src/tools/auditTask.ts +99 -59
  195. package/src/tools/createDirectSession.ts +3 -3
  196. package/src/tools/createTask.ts +7 -7
  197. package/src/tools/dispatch/coreDispatch.ts +374 -0
  198. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  199. package/src/tools/dispatch/directDispatch.ts +167 -0
  200. package/src/tools/dispatch/goalDispatch.ts +127 -0
  201. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  202. package/src/tools/dispatch/types.ts +24 -0
  203. package/src/tools/evidencePack.ts +291 -6
  204. package/src/tools/finalizeDirectSession.ts +4 -4
  205. package/src/tools/goalSubgoalTask.ts +2 -2
  206. package/src/tools/healthCheck.ts +3 -3
  207. package/src/tools/registry.ts +68 -628
  208. package/src/tools/retryTask.ts +2 -2
  209. package/src/tools/runTaskLoop.ts +4 -4
  210. package/src/tools/safeViews.ts +2 -2
  211. package/src/tools/toolRegistry.ts +22 -0
  212. package/src/version.ts +1 -1
  213. package/ui/pages/audit.html +192 -3
  214. package/ui/pages/dashboard.html +959 -44
  215. package/ui/pages/direct-sessions.html +505 -53
  216. package/ui/pages/task-detail.html +456 -438
  217. package/ui/pages/tasks.html +598 -61
@@ -1,6 +1,6 @@
1
1
  import { describe, it, beforeEach, afterEach } from "node:test";
2
2
  import { strict as assert } from "node:assert";
3
- import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { reloadConfig } from "../../config.js";
@@ -126,5 +126,99 @@ describe("v1.5 evidence packs and agent recommendations", () => {
126
126
  const listed = listEvidencePacks();
127
127
  assert.equal(listed.total, 1);
128
128
  assert.equal(listed.evidence_packs[0].lineage_id, "lineage_v15_pack");
129
+ // v2: all six structured artifact files should exist.
130
+ for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"]) {
131
+ assert.ok(existsSync(pack.files[key]), `${key} file should exist`);
132
+ }
133
+ // v2: attestation.json must carry the required provenance fields.
134
+ const attestation = JSON.parse(readFileSync(pack.files.attestation, "utf-8"));
135
+ assert.ok(attestation.patchwarden_version, "attestation should have patchwarden_version");
136
+ assert.ok(attestation.package_version, "attestation should have package_version");
137
+ assert.ok(attestation.commit, "attestation should have commit");
138
+ assert.ok(attestation.node_version, "attestation should have node_version");
139
+ assert.ok(attestation.os, "attestation should have os");
140
+ assert.ok(attestation.schema_epoch, "attestation should have schema_epoch");
141
+ // v2: redactions.json must have the audit structure even when empty.
142
+ const redactions = JSON.parse(readFileSync(pack.files.redactions, "utf-8"));
143
+ assert.ok(Array.isArray(redactions.redactions), "redactions should be an array");
144
+ assert.equal(typeof redactions.total_redacted, "number", "total_redacted should be a number");
145
+ });
146
+ it("exports v2 structured artifacts and redacts secrets in diffstat", () => {
147
+ const secretValue = "ghp_1234567890abcdefghijklmnop";
148
+ // Create a task directory with file-stats.json containing a secret in a file path.
149
+ const taskDir = join(tempDir, ".patchwarden", "tasks", "task-v2-secret");
150
+ mkdirSync(taskDir, { recursive: true });
151
+ writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
152
+ task_id: "task-v2-secret",
153
+ additions: 5,
154
+ deletions: 2,
155
+ files: [{
156
+ path: `secrets/${secretValue}.json`,
157
+ status: "modified",
158
+ additions: 5,
159
+ deletions: 2,
160
+ }],
161
+ }), "utf-8");
162
+ writeTaskLineage({
163
+ lineage_id: "lineage_v2_secret",
164
+ goal: "Export v2 evidence with diffstat",
165
+ repo_path: ".",
166
+ created_at: "2026-07-09T12:00:00.000Z",
167
+ updated_at: "2026-07-09T12:01:00.000Z",
168
+ final_status: "accepted",
169
+ stop_reason: "success",
170
+ next_action: "accept",
171
+ main_task: "task-v2-secret",
172
+ fix_tasks: [],
173
+ cleanup_tasks: [],
174
+ direct_sessions: [],
175
+ rounds: [{
176
+ iteration: 1,
177
+ task_id: "task-v2-secret",
178
+ role: "main",
179
+ status: "done_by_agent",
180
+ terminal: true,
181
+ verification_status: "passed",
182
+ audit_verdict: "pass",
183
+ fail_checks: [],
184
+ warn_checks: ["minor scope drift"],
185
+ next_action: "accept",
186
+ }],
187
+ warnings: [],
188
+ errors: [],
189
+ worktree: {
190
+ isolation_mode: "current_repo",
191
+ cleanup: "keep",
192
+ status: "not_used",
193
+ next_action: "none",
194
+ },
195
+ });
196
+ const pack = exportTaskEvidencePack({ lineage_id: "lineage_v2_secret" });
197
+ // All six v2 files should exist.
198
+ for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"]) {
199
+ assert.ok(existsSync(pack.files[key]), `${key} file should exist`);
200
+ }
201
+ // The original secret must not appear in any v2 file.
202
+ for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"]) {
203
+ const content = readFileSync(pack.files[key], "utf-8");
204
+ assert.ok(!content.includes(secretValue), `${key} should not contain the original secret value`);
205
+ }
206
+ // redactions.json should have detected the secret via diffstat aggregation.
207
+ const redactions = JSON.parse(readFileSync(pack.files.redactions, "utf-8"));
208
+ assert.ok(redactions.total_redacted > 0, "redactions.json should have detected the secret");
209
+ assert.ok(!JSON.stringify(redactions).includes(secretValue), "redactions.json should not contain the original secret value");
210
+ // risk.json should carry the warn_check as a medium-severity risk.
211
+ const risk = JSON.parse(readFileSync(pack.files.risk, "utf-8"));
212
+ assert.ok(risk.count > 0, "risk.json should have at least one risk item");
213
+ assert.equal(risk.by_severity.medium, 1, "should have one medium-severity risk");
214
+ // verify.json should carry the round verification record.
215
+ const verify = JSON.parse(readFileSync(pack.files.verify, "utf-8"));
216
+ assert.ok(verify.count > 0, "verify.json should have at least one record");
217
+ assert.equal(verify.records[0].verification_status, "passed");
218
+ // lineage.json should carry the bounded summary.
219
+ const lineageSummary = JSON.parse(readFileSync(pack.files.lineage, "utf-8"));
220
+ assert.equal(lineageSummary.lineage_id, "lineage_v2_secret");
221
+ assert.equal(lineageSummary.iterations_count, 1);
222
+ assert.equal(lineageSummary.final_status, "accepted");
129
223
  });
130
224
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,174 @@
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 { getTaskStatus } from "../../tools/getTaskStatus.js";
8
+ let tempDir;
9
+ let tasksDir;
10
+ let prevConfigEnv;
11
+ function writeConfig() {
12
+ const configPath = join(tempDir, "patchwarden.config.json");
13
+ writeFileSync(configPath, JSON.stringify({
14
+ workspaceRoot: tempDir,
15
+ tasksDir: ".patchwarden/tasks",
16
+ plansDir: ".patchwarden/plans",
17
+ assessmentsDir: ".patchwarden/assessments",
18
+ directSessionsDir: ".patchwarden/direct-sessions",
19
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
20
+ allowedTestCommands: ["npm test"],
21
+ }), "utf-8");
22
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
23
+ process.env.PATCHWARDEN_CONFIG = configPath;
24
+ reloadConfig();
25
+ }
26
+ function writeTaskStatus(taskId, status) {
27
+ const taskDir = join(tasksDir, taskId);
28
+ mkdirSync(taskDir, { recursive: true });
29
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
30
+ return taskDir;
31
+ }
32
+ function writeTaskRuntime(taskDir, runtime) {
33
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify(runtime, null, 2), "utf-8");
34
+ }
35
+ describe("getTaskStatus", () => {
36
+ beforeEach(() => {
37
+ tempDir = mkdtempSync(join(tmpdir(), "pw-getstatus-"));
38
+ tasksDir = join(tempDir, ".patchwarden", "tasks");
39
+ mkdirSync(tasksDir, { recursive: true });
40
+ writeConfig();
41
+ });
42
+ afterEach(() => {
43
+ if (prevConfigEnv === undefined)
44
+ delete process.env.PATCHWARDEN_CONFIG;
45
+ else
46
+ process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
47
+ reloadConfig();
48
+ rmSync(tempDir, { recursive: true, force: true });
49
+ });
50
+ // ── pending ──
51
+ it("reads pending status task", () => {
52
+ writeTaskStatus("task-pending-001", {
53
+ task_id: "task-pending-001",
54
+ plan_id: "plan-001",
55
+ agent: "codex",
56
+ workspace_root: tempDir,
57
+ repo_path: "repo",
58
+ resolved_repo_path: join(tempDir, "repo"),
59
+ status: "pending",
60
+ phase: "queued",
61
+ created_at: "2026-07-12T10:00:00Z",
62
+ updated_at: "2026-07-12T10:00:01Z",
63
+ timeout_seconds: 900,
64
+ error: null,
65
+ });
66
+ const result = getTaskStatus("task-pending-001");
67
+ assert.equal(result.task_id, "task-pending-001");
68
+ assert.equal(result.status, "pending");
69
+ assert.equal(result.phase, "queued");
70
+ assert.equal(result.agent, "codex");
71
+ assert.equal(result.plan_id, "plan-001");
72
+ assert.equal(result.error, null);
73
+ // No watcher heartbeat → watcher missing → pending task is execution_blocked
74
+ assert.equal(result.execution_blocked, true);
75
+ assert.equal(result.watcher_status, "missing");
76
+ assert.equal(result.pending_reason, "queued_but_watcher_missing");
77
+ });
78
+ // ── running ──
79
+ it("reads running status task and merges runtime.json", () => {
80
+ const taskDir = writeTaskStatus("task-running-001", {
81
+ task_id: "task-running-001",
82
+ plan_id: "plan-001",
83
+ agent: "codex",
84
+ workspace_root: tempDir,
85
+ repo_path: "repo",
86
+ resolved_repo_path: join(tempDir, "repo"),
87
+ status: "running",
88
+ phase: "executing_agent",
89
+ created_at: "2026-07-12T10:00:00Z",
90
+ started_at: "2026-07-12T10:00:05Z",
91
+ updated_at: "2026-07-12T10:00:10Z",
92
+ timeout_seconds: 900,
93
+ error: null,
94
+ });
95
+ writeTaskRuntime(taskDir, {
96
+ phase: "executing_agent",
97
+ last_heartbeat_at: "2026-07-12T10:00:12Z",
98
+ current_command: "codex exec --prompt",
99
+ });
100
+ const result = getTaskStatus("task-running-001");
101
+ assert.equal(result.status, "running");
102
+ assert.equal(result.phase, "executing_agent");
103
+ assert.equal(result.current_command, "codex exec --prompt");
104
+ assert.equal(result.last_heartbeat_at, "2026-07-12T10:00:12Z");
105
+ assert.equal(result.started_at, "2026-07-12T10:00:05Z");
106
+ // running (not pending) → execution_blocked is false
107
+ assert.equal(result.execution_blocked, false);
108
+ assert.equal(result.pending_reason, "agent_running");
109
+ });
110
+ // ── done ──
111
+ it("reads done status task", () => {
112
+ writeTaskStatus("task-done-001", {
113
+ task_id: "task-done-001",
114
+ plan_id: "plan-001",
115
+ agent: "codex",
116
+ workspace_root: tempDir,
117
+ repo_path: "repo",
118
+ resolved_repo_path: join(tempDir, "repo"),
119
+ status: "done",
120
+ phase: "completed",
121
+ created_at: "2026-07-12T10:00:00Z",
122
+ started_at: "2026-07-12T10:00:05Z",
123
+ finished_at: "2026-07-12T10:05:00Z",
124
+ updated_at: "2026-07-12T10:05:00Z",
125
+ timeout_seconds: 900,
126
+ verify_status: "passed",
127
+ error: null,
128
+ });
129
+ const result = getTaskStatus("task-done-001");
130
+ assert.equal(result.status, "done");
131
+ assert.equal(result.phase, "completed");
132
+ assert.equal(result.finished_at, "2026-07-12T10:05:00Z");
133
+ assert.equal(result.verify_status, "passed");
134
+ assert.equal(result.execution_blocked, false);
135
+ assert.equal(result.pending_reason, null);
136
+ });
137
+ // ── failed ──
138
+ it("reads failed status task", () => {
139
+ writeTaskStatus("task-failed-001", {
140
+ task_id: "task-failed-001",
141
+ plan_id: "plan-001",
142
+ agent: "codex",
143
+ workspace_root: tempDir,
144
+ repo_path: "repo",
145
+ resolved_repo_path: join(tempDir, "repo"),
146
+ status: "failed",
147
+ phase: "failed",
148
+ created_at: "2026-07-12T10:00:00Z",
149
+ started_at: "2026-07-12T10:00:05Z",
150
+ finished_at: "2026-07-12T10:05:00Z",
151
+ updated_at: "2026-07-12T10:05:00Z",
152
+ timeout_seconds: 900,
153
+ error: "Agent exited with code 1",
154
+ });
155
+ const result = getTaskStatus("task-failed-001");
156
+ assert.equal(result.status, "failed");
157
+ assert.equal(result.phase, "failed");
158
+ assert.equal(result.error, "Agent exited with code 1");
159
+ assert.equal(result.finished_at, "2026-07-12T10:05:00Z");
160
+ assert.equal(result.execution_blocked, false);
161
+ });
162
+ // ── status.json missing ──
163
+ it("throws when status.json is missing (task not found)", () => {
164
+ // guardReadPath throws "File not found" before the existsSync "Task not found" check
165
+ assert.throws(() => getTaskStatus("non-existent-task"), /(File not found|Task not found)/);
166
+ });
167
+ // ── status.json corrupt ──
168
+ it("throws when status.json is corrupt", () => {
169
+ const taskDir = join(tasksDir, "task-corrupt-001");
170
+ mkdirSync(taskDir, { recursive: true });
171
+ writeFileSync(join(taskDir, "status.json"), "{not valid json", "utf-8");
172
+ assert.throws(() => getTaskStatus("task-corrupt-001"), SyntaxError);
173
+ });
174
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,146 @@
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
+ let tempDir;
9
+ let tasksDir;
10
+ let prevConfigEnv;
11
+ function writeConfig() {
12
+ const configPath = join(tempDir, "patchwarden.config.json");
13
+ writeFileSync(configPath, JSON.stringify({
14
+ workspaceRoot: tempDir,
15
+ tasksDir: ".patchwarden/tasks",
16
+ plansDir: ".patchwarden/plans",
17
+ assessmentsDir: ".patchwarden/assessments",
18
+ directSessionsDir: ".patchwarden/direct-sessions",
19
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
20
+ allowedTestCommands: ["npm test"],
21
+ }), "utf-8");
22
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
23
+ process.env.PATCHWARDEN_CONFIG = configPath;
24
+ reloadConfig();
25
+ }
26
+ function writeTaskStatus(taskId, status) {
27
+ const taskDir = join(tasksDir, taskId);
28
+ mkdirSync(taskDir, { recursive: true });
29
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
30
+ return taskDir;
31
+ }
32
+ function baseStatus(taskId, overrides = {}) {
33
+ return {
34
+ task_id: taskId,
35
+ plan_id: "plan-001",
36
+ agent: "codex",
37
+ workspace_root: tempDir,
38
+ repo_path: "repo",
39
+ resolved_repo_path: join(tempDir, "repo"),
40
+ created_at: "2026-07-12T10:00:00Z",
41
+ started_at: "2026-07-12T10:00:05Z",
42
+ finished_at: "2026-07-12T10:05:00Z",
43
+ updated_at: "2026-07-12T10:05:00Z",
44
+ timeout_seconds: 900,
45
+ error: null,
46
+ ...overrides,
47
+ };
48
+ }
49
+ describe("getTaskSummary", () => {
50
+ beforeEach(() => {
51
+ tempDir = mkdtempSync(join(tmpdir(), "pw-getsummary-"));
52
+ tasksDir = join(tempDir, ".patchwarden", "tasks");
53
+ mkdirSync(tasksDir, { recursive: true });
54
+ writeConfig();
55
+ });
56
+ afterEach(() => {
57
+ if (prevConfigEnv === undefined)
58
+ delete process.env.PATCHWARDEN_CONFIG;
59
+ else
60
+ process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
61
+ reloadConfig();
62
+ rmSync(tempDir, { recursive: true, force: true });
63
+ });
64
+ // ── compact mode ──
65
+ it("returns compact summary in compact mode", () => {
66
+ writeTaskStatus("task-compact-001", baseStatus("task-compact-001", {
67
+ status: "done",
68
+ phase: "completed",
69
+ verify_status: "passed",
70
+ }));
71
+ const result = getTaskSummary("task-compact-001", { view: "compact" });
72
+ assert.equal(result.view, "compact");
73
+ assert.equal(result.task_id, "task-compact-001");
74
+ assert.equal(result.status, "done");
75
+ assert.equal(result.terminal, true);
76
+ // compact-only fields
77
+ assert.equal(typeof result.changed_files_total, "number");
78
+ assert.equal(typeof result.release_artifacts_count, "number");
79
+ assert.ok(result.artifact_hygiene);
80
+ assert.ok("max_items" in result.artifact_hygiene);
81
+ });
82
+ // ── full / standard mode ──
83
+ it("returns full summary in standard mode", () => {
84
+ writeTaskStatus("task-full-001", baseStatus("task-full-001", {
85
+ status: "done",
86
+ phase: "completed",
87
+ verify_status: "passed",
88
+ }));
89
+ const result = getTaskSummary("task-full-001");
90
+ // standard mode has no `view` field
91
+ assert.equal(result.view, undefined);
92
+ assert.equal(result.task_id, "task-full-001");
93
+ assert.equal(result.status, "done");
94
+ assert.equal(result.terminal, true);
95
+ // full-mode-only fields
96
+ assert.ok("log_tails" in result);
97
+ assert.ok("artifacts" in result);
98
+ assert.ok("failed_command_detail" in result);
99
+ assert.ok("acceptance_reviewed_at" in result);
100
+ });
101
+ // ── task not found ──
102
+ it("throws when task does not exist", () => {
103
+ assert.throws(() => getTaskSummary("non-existent-task"), /(File not found|Task not found)/);
104
+ });
105
+ // ── terminal: done ──
106
+ it("returns terminal summary for done task with passed verification", () => {
107
+ writeTaskStatus("task-done-001", baseStatus("task-done-001", {
108
+ status: "done",
109
+ phase: "completed",
110
+ verify_status: "passed",
111
+ }));
112
+ const result = getTaskSummary("task-done-001");
113
+ assert.equal(result.terminal, true);
114
+ assert.equal(result.status, "done");
115
+ assert.equal(result.verify_status, "passed");
116
+ // done + verify passed → ready_for_review
117
+ assert.equal(result.acceptance_status, "ready_for_review");
118
+ });
119
+ // ── terminal: failed ──
120
+ it("returns terminal summary for failed task", () => {
121
+ writeTaskStatus("task-failed-001", baseStatus("task-failed-001", {
122
+ status: "failed",
123
+ phase: "failed",
124
+ error: "Agent exited with code 1",
125
+ }));
126
+ const result = getTaskSummary("task-failed-001");
127
+ assert.equal(result.terminal, true);
128
+ assert.equal(result.status, "failed");
129
+ assert.equal(result.acceptance_status, "failed");
130
+ assert.ok(result.errors.includes("Agent exited with code 1"));
131
+ });
132
+ // ── running (non-terminal) ──
133
+ it("returns non-terminal summary for running task", () => {
134
+ writeTaskStatus("task-running-001", baseStatus("task-running-001", {
135
+ status: "running",
136
+ phase: "executing_agent",
137
+ started_at: "2026-07-12T10:00:00Z",
138
+ finished_at: undefined,
139
+ }));
140
+ const result = getTaskSummary("task-running-001");
141
+ assert.equal(result.terminal, false);
142
+ assert.equal(result.status, "running");
143
+ assert.equal(result.phase, "executing_agent");
144
+ assert.equal(result.acceptance_status, "pending");
145
+ });
146
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,159 @@
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
+ // ── Helpers ───────────────────────────────────────────────────────
10
+ function makeSubgoal(overrides) {
11
+ return {
12
+ title: "Subgoal " + overrides.id,
13
+ status: "ready",
14
+ depends_on: [],
15
+ task_ids: [],
16
+ ...overrides,
17
+ };
18
+ }
19
+ function makeGoalStatus(subgoals, overrides = {}) {
20
+ const now = "2026-07-10T00:00:00.000Z";
21
+ return {
22
+ goal_id: "goal_test_001",
23
+ title: "Test Goal",
24
+ status: "active",
25
+ repo_path: "/repo/test",
26
+ created_at: now,
27
+ updated_at: now,
28
+ subgoals,
29
+ ...overrides,
30
+ };
31
+ }
32
+ let tempDir;
33
+ let prevConfigEnv;
34
+ function writeConfig() {
35
+ const configPath = join(tempDir, "patchwarden.config.json");
36
+ writeFileSync(configPath, JSON.stringify({
37
+ workspaceRoot: tempDir,
38
+ tasksDir: ".patchwarden/tasks",
39
+ plansDir: ".patchwarden/plans",
40
+ assessmentsDir: ".patchwarden/assessments",
41
+ agents: {
42
+ codex: { command: "codex", args: [] },
43
+ opencode: { command: "opencode", args: [] },
44
+ },
45
+ allowedTestCommands: ["npm test", "npm run build"],
46
+ defaultTaskTimeoutSeconds: 30,
47
+ maxTaskTimeoutSeconds: 120,
48
+ }), "utf-8");
49
+ prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
50
+ process.env.PATCHWARDEN_CONFIG = configPath;
51
+ reloadConfig();
52
+ }
53
+ /**
54
+ * 创建 goal 目录并写入 goal_status.json(writeGoalStatus 不自动创建目录)。
55
+ */
56
+ function seedGoalStatus(goalId, status, workspaceRoot) {
57
+ mkdirSync(getGoalDir(goalId, workspaceRoot), { recursive: true });
58
+ writeGoalStatus(goalId, status, workspaceRoot);
59
+ }
60
+ // ── Tests ─────────────────────────────────────────────────────────
61
+ describe("goalReport", () => {
62
+ beforeEach(() => {
63
+ tempDir = mkdtempSync(join(tmpdir(), "pw-goal-report-"));
64
+ writeConfig();
65
+ });
66
+ afterEach(() => {
67
+ if (prevConfigEnv === undefined)
68
+ delete process.env.PATCHWARDEN_CONFIG;
69
+ else
70
+ process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
71
+ reloadConfig();
72
+ rmSync(tempDir, { recursive: true, force: true });
73
+ });
74
+ it("完成 Goal 报告:所有 subgoal accepted,completion_rate=100", () => {
75
+ const goal = makeGoalStatus([
76
+ makeSubgoal({
77
+ id: "subgoal-001",
78
+ title: "Feature A",
79
+ status: "accepted",
80
+ accepted_at: "2026-07-01T00:00:00.000Z",
81
+ }),
82
+ makeSubgoal({
83
+ id: "subgoal-002",
84
+ title: "Feature B",
85
+ status: "accepted",
86
+ accepted_at: "2026-07-02T00:00:00.000Z",
87
+ }),
88
+ ], { goal_id: "goal_done_001" });
89
+ seedGoalStatus("goal_done_001", goal, tempDir);
90
+ const report = exportGoalReport("goal_done_001", { workspaceRoot: tempDir });
91
+ assert.equal(report.completion_rate, 100);
92
+ assert.equal(report.goal_id, "goal_done_001");
93
+ assert.equal(report.subgoals.length, 2);
94
+ assert.equal(report.bounded, true);
95
+ assert.ok(existsSync(report.files.report_md));
96
+ assert.ok(existsSync(report.files.report_json));
97
+ });
98
+ it("未完成 Goal 报告:有 running/ready subgoal,报告含 '未完成'", () => {
99
+ const goal = makeGoalStatus([
100
+ makeSubgoal({
101
+ id: "subgoal-001",
102
+ title: "Done",
103
+ status: "accepted",
104
+ accepted_at: "2026-07-01T00:00:00.000Z",
105
+ }),
106
+ makeSubgoal({ id: "subgoal-002", title: "WIP", status: "running" }),
107
+ ]);
108
+ seedGoalStatus("goal_wip_001", goal, tempDir);
109
+ const report = exportGoalReport("goal_wip_001", { workspaceRoot: tempDir });
110
+ const md = readFileSync(report.files.report_md, "utf-8");
111
+ assert.ok(md.includes("未完成"), "REPORT.md should contain '未完成' annotation");
112
+ assert.ok(report.risks.some((r) => r.includes("未完成")), "risks array should contain '未完成' entry");
113
+ });
114
+ it("空 Goal 报告:无 subgoal,completion_rate=0,报告含 '无子目标'", () => {
115
+ const goal = makeGoalStatus([]);
116
+ seedGoalStatus("goal_empty_001", goal, tempDir);
117
+ const report = exportGoalReport("goal_empty_001", { workspaceRoot: tempDir });
118
+ assert.equal(report.completion_rate, 0);
119
+ const md = readFileSync(report.files.report_md, "utf-8");
120
+ assert.ok(md.includes("无子目标"), "REPORT.md should contain '无子目标' annotation");
121
+ });
122
+ it("双格式输出:REPORT.md 和 report.json 同时存在", () => {
123
+ const goal = makeGoalStatus([
124
+ makeSubgoal({
125
+ id: "subgoal-001",
126
+ title: "Task A",
127
+ status: "accepted",
128
+ accepted_at: "2026-07-01T00:00:00.000Z",
129
+ }),
130
+ ]);
131
+ seedGoalStatus("goal_dual_001", goal, tempDir);
132
+ const report = exportGoalReport("goal_dual_001", { workspaceRoot: tempDir });
133
+ const reportDir = resolve(tempDir, ".patchwarden", "goals", "goal_dual_001", "report");
134
+ const mdPath = join(reportDir, "REPORT.md");
135
+ const jsonPath = join(reportDir, "report.json");
136
+ assert.ok(existsSync(mdPath), "REPORT.md should exist");
137
+ assert.ok(existsSync(jsonPath), "report.json should exist");
138
+ assert.equal(report.files.report_md, mdPath);
139
+ assert.equal(report.files.report_json, jsonPath);
140
+ });
141
+ it("脱敏验证:subgoal title 含敏感信息,输出中不含原始值", () => {
142
+ const goal = makeGoalStatus([
143
+ makeSubgoal({
144
+ id: "subgoal-001",
145
+ title: "Deploy with token=abcd1234",
146
+ status: "accepted",
147
+ accepted_at: "2026-07-01T00:00:00.000Z",
148
+ }),
149
+ ]);
150
+ seedGoalStatus("goal_redact_001", goal, tempDir);
151
+ const report = exportGoalReport("goal_redact_001", { workspaceRoot: tempDir });
152
+ const md = readFileSync(report.files.report_md, "utf-8");
153
+ const json = readFileSync(report.files.report_json, "utf-8");
154
+ assert.ok(!md.includes("abcd1234"), "REPORT.md must not contain raw secret value");
155
+ assert.ok(!json.includes("abcd1234"), "report.json must not contain raw secret value");
156
+ assert.ok(md.includes("[REDACTED]"), "REPORT.md should contain redaction marker");
157
+ assert.ok(json.includes("[REDACTED]"), "report.json should contain redaction marker");
158
+ });
159
+ });
@@ -11,8 +11,8 @@ import { PatchWardenError } from "../../errors.js";
11
11
  // - goal_not_found:readGoalStatus 在默认工作区找不到 goal 即抛错
12
12
  describe("createSubgoalTask", () => {
13
13
  describe("错误路径", () => {
14
- it("execution_mode=assess_only 抛 invalid_execution_mode", () => {
15
- assert.throws(() => createSubgoalTask({
14
+ it("execution_mode=assess_only 抛 invalid_execution_mode", async () => {
15
+ await assert.rejects(() => createSubgoalTask({
16
16
  goal_id: "goal_nonexistent_test",
17
17
  subgoal_title: "Sub A",
18
18
  repo_path: "repo",
@@ -24,8 +24,8 @@ describe("createSubgoalTask", () => {
24
24
  return true;
25
25
  });
26
26
  });
27
- it("goal_id 不存在抛 goal_not_found", () => {
28
- assert.throws(() => createSubgoalTask({
27
+ it("goal_id 不存在抛 goal_not_found", async () => {
28
+ await assert.rejects(() => createSubgoalTask({
29
29
  goal_id: "goal_definitely_does_not_exist_99999",
30
30
  subgoal_title: "Sub A",
31
31
  repo_path: "repo",
@@ -37,10 +37,10 @@ describe("createSubgoalTask", () => {
37
37
  return true;
38
38
  });
39
39
  });
40
- it("assess_only 优先于 goal_not_found(在读取 goal 前校验)", () => {
40
+ it("assess_only 优先于 goal_not_found(在读取 goal 前校验)", async () => {
41
41
  // execution_mode=assess_only 应先抛 invalid_execution_mode,
42
42
  // 而不是去读不存在的 goal
43
- assert.throws(() => createSubgoalTask({
43
+ await assert.rejects(() => createSubgoalTask({
44
44
  goal_id: "goal_definitely_does_not_exist_99999",
45
45
  subgoal_title: "Sub A",
46
46
  repo_path: "repo",