patchwarden 0.4.0 → 0.6.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 (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. package/docs/release-v0.4.0.md +0 -74
@@ -6,29 +6,39 @@ import {
6
6
  writeFileSync,
7
7
  type WriteStream,
8
8
  } from "node:fs";
9
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
9
+ import { isAbsolute, join, relative, resolve } from "node:path";
10
10
  import { getTasksDir, getPlansDir, getConfig, resolveWorkspaceRoot } from "../config.js";
11
11
  import { guardPath, guardWorkspacePath } from "../security/pathGuard.js";
12
12
  import {
13
- guardAgentCommand,
14
13
  guardTestCommand,
15
- sanitizePromptArg,
16
14
  } from "../security/commandGuard.js";
17
15
  import { writeTaskProgress } from "../taskProgress.js";
18
16
  import { writeTaskRuntime } from "../taskRuntime.js";
17
+ import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
18
+ import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
19
19
  import type { TaskPhase, TaskStatus } from "../tools/createTask.js";
20
20
  import {
21
21
  buildChangeArtifacts,
22
22
  captureRepoSnapshot,
23
23
  compareSnapshots,
24
+ emptyArtifactHygiene,
24
25
  writeSnapshot,
26
+ extractExternalDirtyFiles,
27
+ findNewExternalDirtyFiles,
28
+ buildArtifactManifest,
29
+ groupChangedFiles,
25
30
  type ChangedFile,
26
31
  type ChangeArtifacts,
32
+ type ExternalDirtyFile,
33
+ type ArtifactManifest,
27
34
  } from "./changeCapture.js";
35
+ import { PatchWardenError, errorPayload } from "../errors.js";
36
+ import { diagnoseAndroidBuild } from "../tools/androidDoctor.js";
28
37
 
29
38
  const HEARTBEAT_INTERVAL_MS = 2000;
30
39
  const GRACEFUL_KILL_MS = 2000;
31
40
  const MAX_CAPTURE_CHARS = 100_000;
41
+ const ARTIFACT_COLLECTION_TIMEOUT_MS = 60_000;
32
42
 
33
43
  interface TaskRunResult {
34
44
  task_id: string;
@@ -94,10 +104,9 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
94
104
  let verifyCommands: string[];
95
105
  let timeoutSeconds: number;
96
106
  try {
97
- verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config);
98
107
  timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
99
108
  } catch (error) {
100
- return failBeforeExecution(taskId, taskDir, errorMessage(error));
109
+ return failBeforeExecution(taskId, taskDir, errorMessage(error), error);
101
110
  }
102
111
  const startedAtMs = Date.now();
103
112
  const deadlineMs = startedAtMs + timeoutSeconds * 1000;
@@ -107,7 +116,29 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
107
116
  repoPath = guardWorkspacePath(rawRepoPath, wsRoot);
108
117
  } catch (error) {
109
118
  const message = `repo_path validation failed: ${errorMessage(error)}`;
110
- return failBeforeExecution(taskId, taskDir, message);
119
+ return failBeforeExecution(taskId, taskDir, message, error);
120
+ }
121
+ try {
122
+ verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
123
+ } catch (error) {
124
+ const message = `verification metadata validation failed: ${errorMessage(error)}`;
125
+ return failBeforeExecution(taskId, taskDir, message, error);
126
+ }
127
+
128
+ // ── Assessment freshness revalidation before execution ──
129
+ const assessmentId = String(initialStatus.assessment_id || "");
130
+ if (assessmentId) {
131
+ try {
132
+ const preExecSnapshot = captureRepoSnapshot(repoPath);
133
+ const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
134
+ if (!validation.valid) {
135
+ const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
136
+ return failBeforeExecution(taskId, taskDir, message);
137
+ }
138
+ } catch (error) {
139
+ const message = `assessment validation error: ${errorMessage(error)}`;
140
+ return failBeforeExecution(taskId, taskDir, message, error);
141
+ }
111
142
  }
112
143
 
113
144
  updateStatus(taskDir, {
@@ -123,14 +154,17 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
123
154
 
124
155
  let beforeSnapshot;
125
156
  let beforeWorkspaceSnapshot;
157
+ let externalDirtyBaseline: ExternalDirtyFile[] = [];
126
158
  try {
127
159
  beforeSnapshot = captureRepoSnapshot(repoPath);
128
160
  beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : captureRepoSnapshot(wsRoot);
129
161
  writeSnapshot(taskDir, "git-before.json", beforeSnapshot);
130
162
  writeSnapshot(taskDir, "workspace-before.json", beforeWorkspaceSnapshot);
163
+ // Phase 4: Record external dirty files as baseline before task execution
164
+ externalDirtyBaseline = extractExternalDirtyFiles(beforeWorkspaceSnapshot, repoPath, wsRoot);
131
165
  } catch (error) {
132
166
  const message = `Pre-task snapshot failed: ${errorMessage(error)}`;
133
- return failBeforeExecution(taskId, taskDir, message);
167
+ return failBeforeExecution(taskId, taskDir, message, error);
134
168
  }
135
169
 
136
170
  let agentResult: ManagedProcessResult | null = null;
@@ -138,24 +172,20 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
138
172
  const verifyResults: TestExecutionResult[] = [];
139
173
  let finalStatus: TaskStatus = "failed";
140
174
  let finalError: string | null = null;
175
+ let lastCaughtError: unknown = null;
141
176
 
142
177
  try {
143
178
  const planFile = resolve(plansDir, planId, "plan.md");
144
179
  if (!existsSync(planFile)) throw new Error(`Plan not found: "${planId}". Save the plan first.`);
145
180
  const planContent = readFileSync(planFile, "utf-8");
146
- const agentCmd = guardAgentCommand(agentName, config);
147
- const prompt = sanitizePromptArg(buildExecutionPrompt(planContent, repoPath, testCommand));
148
- const resolvedArgs = agentCmd.args.map((arg) => {
149
- if (arg === "{repo}") return repoPath;
150
- if (arg === "{prompt}") return prompt;
151
- return arg;
152
- });
153
- const agentCommandLabel = `${basename(agentCmd.command)} (configured agent command)`;
181
+ const prompt = buildExecutionPrompt(planContent, repoPath, testCommand);
182
+ const invocation = buildAgentInvocation(agentName, repoPath, prompt, config);
183
+ const agentCommandLabel = invocation.commandLabel;
154
184
 
155
185
  setTaskPhase(taskDir, "executing_agent", agentCommandLabel);
156
186
  agentResult = await runManagedProcess({
157
- command: agentCmd.command,
158
- args: resolvedArgs,
187
+ command: invocation.command,
188
+ args: invocation.args,
159
189
  cwd: repoPath,
160
190
  taskDir,
161
191
  statusFile,
@@ -209,19 +239,42 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
209
239
  finalStatus = "done";
210
240
  }
211
241
  } catch (error) {
242
+ lastCaughtError = error;
212
243
  finalError = errorMessage(error);
213
244
  }
214
245
 
215
246
  setTaskPhase(taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
247
+ const artifactCollectionStartedAt = new Date().toISOString();
216
248
  let changes: ChangeArtifacts;
249
+ let artifactStatus: "collected" | "partial" | "failed" | "timeout" = "collected";
250
+ let artifactCollectionError: string | null = null;
251
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
217
252
  try {
218
- const afterSnapshot = captureRepoSnapshot(repoPath);
219
- writeSnapshot(taskDir, "git-after.json", afterSnapshot);
220
- changes = buildChangeArtifacts(repoPath, beforeSnapshot, afterSnapshot);
253
+ // Phase 5: Wrap artifact collection with a timeout
254
+ const collectionResult = await Promise.race([
255
+ Promise.resolve().then(() => {
256
+ const afterSnapshot = captureRepoSnapshot(repoPath);
257
+ writeSnapshot(taskDir, "git-after.json", afterSnapshot);
258
+ return buildChangeArtifacts(repoPath, beforeSnapshot, afterSnapshot);
259
+ }),
260
+ new Promise<never>((_, reject) => {
261
+ timeoutHandle = setTimeout(() => reject(new Error("Artifact collection timed out")), ARTIFACT_COLLECTION_TIMEOUT_MS);
262
+ }),
263
+ ]);
264
+ if (timeoutHandle) clearTimeout(timeoutHandle);
265
+ changes = collectionResult;
221
266
  } catch (error) {
267
+ if (timeoutHandle) clearTimeout(timeoutHandle);
268
+ lastCaughtError = error;
269
+ artifactCollectionError = errorMessage(error);
270
+ if (artifactCollectionError.includes("timed out")) {
271
+ artifactStatus = "timeout";
272
+ } else {
273
+ artifactStatus = "failed";
274
+ }
222
275
  changes = {
223
276
  changed_files: [],
224
- diff: `(change capture failed: ${errorMessage(error)})\n`,
277
+ diff: `(change capture failed: ${artifactCollectionError})\n`,
225
278
  diff_available: false,
226
279
  diff_truncated: false,
227
280
  diff_size_bytes: 0,
@@ -231,16 +284,50 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
231
284
  workspace_dirty_before: beforeSnapshot.workspace_dirty,
232
285
  workspace_dirty_after: beforeSnapshot.workspace_dirty,
233
286
  patch_mode: "hash_only",
234
- unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
287
+ unavailable_reason: `Change capture failed: ${artifactCollectionError}`,
288
+ artifact_hygiene: emptyArtifactHygiene(),
235
289
  };
236
- finalError ||= `Change capture failed: ${errorMessage(error)}`;
237
- finalStatus = "failed";
290
+ // Phase 5: Write partial_result.md when artifact collection fails
291
+ writeFileSync(join(taskDir, "partial_result.md"), [
292
+ "# PatchWarden Partial Result",
293
+ "",
294
+ `Task: ${taskId}`,
295
+ "",
296
+ "## Artifact Collection Status",
297
+ artifactStatus,
298
+ "",
299
+ "## Error",
300
+ artifactCollectionError,
301
+ "",
302
+ "## Agent Status",
303
+ agentResult ? `Exit code: ${agentResult.exitCode}` : "Agent did not run",
304
+ "",
305
+ "## Verification Status",
306
+ verifyResults.length > 0 ? verifyResults.map((r) => `- ${r.command}: exit ${r.exitCode}`).join("\n") : "No verification ran",
307
+ "",
308
+ "## Note",
309
+ "Artifact collection did not complete. The task result may be incomplete. Review stdout.log, stderr.log, and verify.json for details.",
310
+ "",
311
+ ].join("\n"), "utf-8");
312
+ // Don't override test failure with artifact failure
313
+ if (finalStatus === "done") {
314
+ finalError = `Change capture failed: ${artifactCollectionError}`;
315
+ finalStatus = "failed";
316
+ } else {
317
+ finalError ||= `Change capture failed: ${artifactCollectionError}`;
318
+ }
238
319
  }
320
+ const artifactCollectionFinishedAt = new Date().toISOString();
239
321
 
240
322
  let outOfScopeChanges: ChangedFile[] = [];
323
+ let preexistingExternalDirty: ExternalDirtyFile[] = externalDirtyBaseline;
324
+ let newOutOfScopeChanges: ExternalDirtyFile[] = [];
241
325
  try {
242
326
  const afterWorkspaceSnapshot = repoPath === wsRoot ? captureRepoSnapshot(repoPath) : captureRepoSnapshot(wsRoot);
243
327
  writeSnapshot(taskDir, "workspace-after.json", afterWorkspaceSnapshot);
328
+ const allExternalDirty = extractExternalDirtyFiles(afterWorkspaceSnapshot, repoPath, wsRoot);
329
+ // Phase 4: Only NEW external dirty files (not in baseline) are scope violations
330
+ newOutOfScopeChanges = findNewExternalDirtyFiles(externalDirtyBaseline, allExternalDirty);
244
331
  outOfScopeChanges = compareSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
245
332
  .filter((file) =>
246
333
  !isPathInside(resolve(wsRoot, file.path), repoPath) ||
@@ -251,15 +338,21 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
251
338
  if (finalStatus === "done") finalStatus = "failed";
252
339
  }
253
340
 
254
- if (outOfScopeChanges.length > 0) {
341
+ // Phase 4: Pre-existing external dirty files are warnings, not failures
342
+ const preexistingWarnings: string[] = preexistingExternalDirty.length > 0
343
+ ? [`Pre-existing external dirty files (not caused by this task): ${preexistingExternalDirty.length} file(s)`]
344
+ : [];
345
+
346
+ if (newOutOfScopeChanges.length > 0) {
255
347
  finalStatus = "failed_scope_violation";
256
- finalError = `Detected ${outOfScopeChanges.length} change(s) outside resolved_repo_path.`;
348
+ finalError = `Detected ${newOutOfScopeChanges.length} new change(s) outside resolved_repo_path during task execution.`;
257
349
  writeFileSync(join(taskDir, "rollback-plan.json"), JSON.stringify({
258
350
  task_id: taskId,
259
351
  status: "review_required",
260
352
  automatic_rollback_performed: false,
261
353
  warning: "Review ownership and concurrent edits before rollback. PatchWarden did not modify or restore these files.",
262
- out_of_scope_changes: outOfScopeChanges,
354
+ out_of_scope_changes: newOutOfScopeChanges,
355
+ preexisting_external_dirty_files: preexistingExternalDirty,
263
356
  }, null, 2), "utf-8");
264
357
  writeFileSync(join(taskDir, "rollback_scope_violation_plan.md"), [
265
358
  "# Scope Violation Rollback Plan",
@@ -268,8 +361,8 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
268
361
  "",
269
362
  "PatchWarden did not automatically roll back any file. Review concurrent or user-owned edits before acting.",
270
363
  "",
271
- "## Out-of-scope files only",
272
- ...outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`),
364
+ "## New out-of-scope files (caused by this task)",
365
+ ...newOutOfScopeChanges.map((file) => `- ${file.change}: ${file.path}`),
273
366
  "",
274
367
  ].join("\n"), "utf-8");
275
368
  } else if (changePolicy === "no_changes" && changes.changed_files.length > 0) {
@@ -280,6 +373,12 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
280
373
  writeFileSync(join(taskDir, "git.diff"), changes.diff, "utf-8");
281
374
  writeFileSync(join(taskDir, "diff.patch"), changes.diff, "utf-8");
282
375
  writeFileSync(join(taskDir, "changed-files.json"), JSON.stringify(changes, null, 2), "utf-8");
376
+
377
+ // Phase 6: Generate artifact_manifest.json and group changed files
378
+ const artifactManifest: ArtifactManifest = buildArtifactManifest(changes.changed_files, repoPath, taskId);
379
+ writeFileSync(join(taskDir, "artifact_manifest.json"), JSON.stringify(artifactManifest, null, 2), "utf-8");
380
+ const changedFileGroups = groupChangedFiles(changes.changed_files);
381
+
283
382
  writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
284
383
  task_id: taskId,
285
384
  additions: changes.additions,
@@ -288,7 +387,10 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
288
387
  }, null, 2), "utf-8");
289
388
  writeFileSync(join(taskDir, "test.log"), buildTestLog(testResult), "utf-8");
290
389
  const verifyJson = buildVerifyJson(verifyCommands, verifyResults, repoPath);
291
- if (outOfScopeChanges.length > 0) {
390
+ // Phase 4: Use newOutOfScopeChanges (not outOfScopeChanges) for verify_status
391
+ // to stay consistent with finalStatus. Pre-existing external dirty files
392
+ // that didn't change during the task should NOT cause verify_status to fail.
393
+ if (newOutOfScopeChanges.length > 0) {
292
394
  verifyJson.status = "failed";
293
395
  verifyJson.failure_reason = "scope_violation";
294
396
  } else if (finalStatus === "failed_policy_violation") {
@@ -301,6 +403,21 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
301
403
  if (!["canceled", "done", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(finalStatus)) finalStatus = "failed";
302
404
  const finalPhase: TaskPhase = finalStatus === "done" ? "completed" : finalStatus;
303
405
  const followup = buildFailureFollowup(finalStatus, finalError, verifyJson.commands);
406
+
407
+ // Phase 7: Run Android build environment diagnostics if android_app exists
408
+ const androidDiagnostic = diagnoseAndroidBuild(repoPath);
409
+ let androidWarning: string | null = null;
410
+ if (androidDiagnostic.status !== "skip") {
411
+ if (androidDiagnostic.status === "fail") {
412
+ androidWarning = "Android project exists, APK not built because Android SDK is missing.";
413
+ } else if (androidDiagnostic.status === "warn") {
414
+ const apkCheck = androidDiagnostic.checks.find((c) => c.check === "APK output path");
415
+ if (apkCheck && apkCheck.status !== "ok") {
416
+ androidWarning = `Android build environment has warnings: ${apkCheck.reason}`;
417
+ }
418
+ }
419
+ }
420
+
304
421
  const resultMd = buildResultMarkdown({
305
422
  taskId,
306
423
  planId,
@@ -312,6 +429,12 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
312
429
  verify: verifyJson,
313
430
  changes,
314
431
  outOfScopeChanges,
432
+ artifactStatus,
433
+ artifactManifest,
434
+ changedFileGroups,
435
+ androidDiagnostic,
436
+ androidWarning,
437
+ preexistingWarnings,
315
438
  });
316
439
  writeFileSync(join(taskDir, "result.md"), resultMd, "utf-8");
317
440
  writeFileSync(join(taskDir, "result.json"), JSON.stringify({
@@ -326,7 +449,26 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
326
449
  change_policy: changePolicy,
327
450
  summary: finalError || "Agent execution and configured verification completed successfully.",
328
451
  changed_files: changes.changed_files,
452
+ changed_file_groups: {
453
+ source_changes: changedFileGroups.source_changes.length,
454
+ docs_changes: changedFileGroups.docs_changes.length,
455
+ config_changes: changedFileGroups.config_changes.length,
456
+ test_changes: changedFileGroups.test_changes.length,
457
+ release_artifacts: changedFileGroups.release_artifacts.length,
458
+ runtime_generated_files: changedFileGroups.runtime_generated_files.length,
459
+ },
460
+ artifact_hygiene: changes.artifact_hygiene,
461
+ artifact_status: artifactStatus,
462
+ artifact_collection_error: artifactCollectionError,
463
+ artifact_collection_started_at: artifactCollectionStartedAt,
464
+ artifact_collection_finished_at: artifactCollectionFinishedAt,
465
+ artifact_manifest: artifactManifest,
329
466
  out_of_scope_changes: outOfScopeChanges,
467
+ new_out_of_scope_changes: newOutOfScopeChanges,
468
+ preexisting_external_dirty_files: preexistingExternalDirty,
469
+ target_repo_status: changes.workspace_dirty_after ? "dirty" : "clean",
470
+ workspace_status: changes.workspace_dirty_after ? "dirty" : "clean",
471
+ android_diagnostic: androidDiagnostic,
330
472
  verify_status: verifyJson.status,
331
473
  verify_commands: verifyJson.commands,
332
474
  commands_run: verifyJson.commands.map((command) => ({
@@ -337,12 +479,15 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
337
479
  commands_observed: [],
338
480
  verify: verifyJson,
339
481
  artifacts: [
340
- "result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json",
482
+ "result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json", "artifact_manifest.json",
341
483
  ...(outOfScopeChanges.length > 0 ? ["rollback_scope_violation_plan.md", "rollback-plan.json"] : []),
484
+ ...(artifactStatus !== "collected" ? ["partial_result.md"] : []),
342
485
  ],
343
486
  warnings: [
344
487
  ...beforeSnapshot.warnings,
345
488
  ...(changes.diff_truncated ? ["diff.patch was truncated; changed-files.json retains file evidence."] : []),
489
+ ...preexistingWarnings,
490
+ ...(androidWarning ? [androidWarning] : []),
346
491
  ],
347
492
  errors: finalError ? [finalError] : [],
348
493
  known_issues: finalError ? [finalError] : [],
@@ -354,7 +499,14 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
354
499
  ? ["Review get_task_summary and audit_task before accepting the work."]
355
500
  : ["Resolve the reported failure before accepting the work."],
356
501
  }, null, 2), "utf-8");
357
- if (finalError) writeFileSync(join(taskDir, "error.log"), finalError, "utf-8");
502
+ if (finalError) {
503
+ // Phase 9: Preserve PatchWardenError structured info in error.log
504
+ const structuredError = errorPayload(lastCaughtError);
505
+ writeFileSync(join(taskDir, "error.log"), JSON.stringify({
506
+ summary: finalError,
507
+ ...structuredError,
508
+ }, null, 2), "utf-8");
509
+ }
358
510
 
359
511
  const finishedAt = new Date().toISOString();
360
512
  updateStatus(taskDir, {
@@ -365,7 +517,14 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
365
517
  finished_at: finishedAt,
366
518
  error: finalError,
367
519
  changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
520
+ artifact_hygiene_counts: changes.artifact_hygiene.counts,
521
+ artifact_status: artifactStatus,
522
+ artifact_collection_error: artifactCollectionError,
523
+ artifact_collection_started_at: artifactCollectionStartedAt,
524
+ artifact_collection_finished_at: artifactCollectionFinishedAt,
368
525
  out_of_scope_changes: outOfScopeChanges,
526
+ new_out_of_scope_changes: newOutOfScopeChanges,
527
+ preexisting_external_dirty_files: preexistingExternalDirty,
369
528
  verify_status: verifyJson.status,
370
529
  verify_commands: verifyCommands,
371
530
  diff_available: changes.diff_available,
@@ -483,6 +642,8 @@ async function runManagedProcess(options: {
483
642
  let spawnError: string | null = null;
484
643
  let terminationReason: TerminationReason = null;
485
644
  let forceTimer: ReturnType<typeof setTimeout> | null = null;
645
+ let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
646
+ let childExitFinish: ((code: number | null) => void) | null = null;
486
647
  let terminationStarted = false;
487
648
 
488
649
  const heartbeat = () => {
@@ -520,6 +681,15 @@ async function runManagedProcess(options: {
520
681
  gracefulKill(child);
521
682
  forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
522
683
  }
684
+ // Fallback: if neither close nor exit fires within 10s after taskkill,
685
+ // force-resolve to prevent the runner from hanging indefinitely.
686
+ // Only starts AFTER termination is requested — normal long tasks are unaffected.
687
+ fallbackTimer = setTimeout(() => {
688
+ try { forceKill(child); } catch {}
689
+ try { child.kill("SIGKILL"); } catch {}
690
+ // Resolve the exit promise via the shared finish handle
691
+ if (childExitFinish) childExitFinish(null);
692
+ }, 10000);
523
693
  };
524
694
 
525
695
  child.stdout?.on("data", (chunk: Buffer) => {
@@ -540,9 +710,18 @@ async function runManagedProcess(options: {
540
710
  const finish = (code: number | null) => {
541
711
  if (settled) return;
542
712
  settled = true;
713
+ if (fallbackTimer) clearTimeout(fallbackTimer);
714
+ childExitFinish = null;
543
715
  resolveExit(code);
544
716
  };
717
+ childExitFinish = finish;
545
718
  child.once("close", (code) => finish(code));
719
+ child.once("exit", (code) => {
720
+ // On Windows, "close" can lag behind "exit" when stdio pipes drain
721
+ // after taskkill. Resolve on exit to avoid hanging the runner, but
722
+ // still let "close" fire if it arrives first.
723
+ finish(code);
724
+ });
546
725
  child.once("error", (error) => {
547
726
  spawnError = error.message;
548
727
  finish(null);
@@ -551,8 +730,8 @@ async function runManagedProcess(options: {
551
730
 
552
731
  clearInterval(heartbeatTimer);
553
732
  if (forceTimer) clearTimeout(forceTimer);
554
- stdoutStream?.end();
555
- stderrStream?.end();
733
+ stdoutStream?.destroy();
734
+ stderrStream?.destroy();
556
735
  writeTaskRuntime(options.taskDir, {
557
736
  phase: options.phase,
558
737
  last_heartbeat_at: new Date().toISOString(),
@@ -572,7 +751,7 @@ async function runTrustedTestCommand(
572
751
  deadlineMs: number
573
752
  ): Promise<TestExecutionResult> {
574
753
  const config = getConfig();
575
- const trusted = guardTestCommand(testCommand, config);
754
+ const trusted = guardTestCommand(testCommand, config, repoPath);
576
755
  const startedAtMs = Date.now();
577
756
  const startedAt = new Date(startedAtMs).toISOString();
578
757
  const parts = trusted.split(/\s+/).filter(Boolean);
@@ -606,29 +785,6 @@ async function runTrustedTestCommand(
606
785
  };
607
786
  }
608
787
 
609
- function buildExecutionPrompt(plan: string, repoPath: string, testCommand: string): string {
610
- let prompt = `You are executing a pre-written plan in a local repository.
611
-
612
- ## Repository
613
- ${repoPath}
614
-
615
- ## Plan
616
- ${plan}
617
-
618
- ## Instructions
619
- 1. Read the plan carefully.
620
- 2. Implement the changes in this repository only.
621
- 3. Do NOT modify files outside this repository.
622
- 4. Do NOT commit or push changes.
623
- 5. After implementing, describe what you changed.
624
- 6. Output a summary with what was done, files modified, and issues encountered.
625
- `;
626
- if (testCommand) {
627
- prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
628
- }
629
- return prompt;
630
- }
631
-
632
788
  function buildTestLog(result: TestExecutionResult): string {
633
789
  if (result.skipped) return `${result.command || "(no test command)"}\nExit code: not run\n${result.stderr}\n`;
634
790
  return [
@@ -653,6 +809,12 @@ function buildResultMarkdown(input: {
653
809
  verify: VerifyReport;
654
810
  changes: ChangeArtifacts;
655
811
  outOfScopeChanges: ChangedFile[];
812
+ artifactStatus?: string;
813
+ artifactManifest?: ArtifactManifest;
814
+ changedFileGroups?: { source_changes: ChangedFile[]; docs_changes: ChangedFile[]; config_changes: ChangedFile[]; test_changes: ChangedFile[]; release_artifacts: ChangedFile[]; runtime_generated_files: ChangedFile[] };
815
+ androidDiagnostic?: { status: string; checks?: Array<{ check: string; status: string; reason: string }> };
816
+ androidWarning?: string | null;
817
+ preexistingWarnings?: string[];
656
818
  }): string {
657
819
  const changed = input.changes.changed_files.length
658
820
  ? input.changes.changed_files.map((file) => `- ${file.change}: ${file.path}`).join("\n")
@@ -660,6 +822,45 @@ function buildResultMarkdown(input: {
660
822
  const outOfScope = input.outOfScopeChanges.length
661
823
  ? input.outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`).join("\n")
662
824
  : "(none)";
825
+
826
+ // Phase 6: Artifact manifest summary
827
+ const artifactCount = input.artifactManifest?.artifacts.length || 0;
828
+ const artifactLines = artifactCount > 0
829
+ ? input.artifactManifest!.artifacts.map((a) => `- ${a.type}: ${a.path} (${a.size} bytes, sha256: ${a.sha256.slice(0, 16)}...)`).join("\n")
830
+ : "(no release artifacts)";
831
+
832
+ // Phase 6: Changed file group summary
833
+ const groupLines = input.changedFileGroups
834
+ ? [
835
+ `- source_changes: ${input.changedFileGroups.source_changes.length}`,
836
+ `- docs_changes: ${input.changedFileGroups.docs_changes.length}`,
837
+ `- config_changes: ${input.changedFileGroups.config_changes.length}`,
838
+ `- test_changes: ${input.changedFileGroups.test_changes.length}`,
839
+ `- release_artifacts: ${input.changedFileGroups.release_artifacts.length}`,
840
+ `- runtime_generated_files: ${input.changedFileGroups.runtime_generated_files.length}`,
841
+ ].join("\n")
842
+ : "";
843
+
844
+ // Phase 7: Android diagnostic summary
845
+ let androidSection = "";
846
+ if (input.androidDiagnostic && input.androidDiagnostic.status !== "skip") {
847
+ const checkLines = (input.androidDiagnostic.checks || [])
848
+ .map((c) => `- [${c.status}] ${c.check}: ${c.reason}`)
849
+ .join("\n");
850
+ androidSection = [
851
+ "## Android Build Environment",
852
+ `Status: ${input.androidDiagnostic.status}`,
853
+ ...(input.androidWarning ? [`Warning: ${input.androidWarning}`] : []),
854
+ checkLines,
855
+ "",
856
+ ].join("\n");
857
+ }
858
+
859
+ // Phase 4: Pre-existing warnings
860
+ const preexistingSection = input.preexistingWarnings && input.preexistingWarnings.length > 0
861
+ ? ["## Pre-existing Warnings", ...input.preexistingWarnings, ""].join("\n")
862
+ : "";
863
+
663
864
  return [
664
865
  "# PatchWarden Task Result",
665
866
  "",
@@ -678,6 +879,12 @@ function buildResultMarkdown(input: {
678
879
  "## Files changed",
679
880
  changed,
680
881
  "",
882
+ "## Changed file groups",
883
+ groupLines,
884
+ "",
885
+ "## Release artifacts",
886
+ artifactLines,
887
+ "",
681
888
  "## Verification",
682
889
  `- diff_available: ${input.changes.diff_available}`,
683
890
  `- diff_truncated: ${input.changes.diff_truncated}`,
@@ -686,10 +893,13 @@ function buildResultMarkdown(input: {
686
893
  `- verify_status: ${input.verify.status}`,
687
894
  `- verify_commands: ${input.verify.commands.length}/${input.verify.requested_commands.length} executed`,
688
895
  `- out_of_scope_changes: ${input.outOfScopeChanges.length}`,
896
+ `- artifact_status: ${input.artifactStatus || "collected"}`,
689
897
  "",
690
898
  "## Out-of-scope changes",
691
899
  outOfScope,
692
900
  "",
901
+ preexistingSection,
902
+ androidSection,
693
903
  "## Summary",
694
904
  input.error || "Agent execution and configured verification completed successfully.",
695
905
  "",
@@ -777,14 +987,15 @@ function buildVerifyLog(commands: VerifyCommandRecord[]): string {
777
987
  function normalizeVerifyCommands(
778
988
  value: unknown,
779
989
  legacyTestCommand: string,
780
- config: ReturnType<typeof getConfig>
990
+ config: ReturnType<typeof getConfig>,
991
+ repoPath: string
781
992
  ): string[] {
782
993
  if (value !== undefined && !Array.isArray(value)) {
783
994
  throw new Error("Invalid task verify_commands metadata; expected an array.");
784
995
  }
785
996
  return [...new Set([
786
- ...((value as unknown[] | undefined) || []).map((command) => guardTestCommand(String(command), config)),
787
- ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config)] : []),
997
+ ...((value as unknown[] | undefined) || []).map((command) => guardTestCommand(String(command), config, repoPath)),
998
+ ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config, repoPath)] : []),
788
999
  ])];
789
1000
  }
790
1001
 
@@ -806,8 +1017,13 @@ function normalizeTimeout(value: unknown, config: ReturnType<typeof getConfig>):
806
1017
  return timeout;
807
1018
  }
808
1019
 
809
- function failBeforeExecution(taskId: string, taskDir: string, message: string): TaskRunResult {
810
- writeFileSync(join(taskDir, "error.log"), message, "utf-8");
1020
+ function failBeforeExecution(taskId: string, taskDir: string, message: string, caughtError?: unknown): TaskRunResult {
1021
+ // Phase 9: Preserve PatchWardenError structured info in error.log
1022
+ const structuredError = caughtError ? errorPayload(caughtError) : { error: message };
1023
+ writeFileSync(join(taskDir, "error.log"), JSON.stringify({
1024
+ summary: message,
1025
+ ...structuredError,
1026
+ }, null, 2), "utf-8");
811
1027
  const current = readStatus(join(taskDir, "status.json"));
812
1028
  const now = new Date().toISOString();
813
1029
  const verify: VerifyReport = {