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
@@ -1,15 +1,20 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { createWriteStream, existsSync, readFileSync, writeFileSync, } from "node:fs";
3
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { getTasksDir, getPlansDir, getConfig, resolveWorkspaceRoot } from "../config.js";
5
5
  import { guardPath, guardWorkspacePath } from "../security/pathGuard.js";
6
- import { guardAgentCommand, guardTestCommand, sanitizePromptArg, } from "../security/commandGuard.js";
6
+ import { guardTestCommand, } from "../security/commandGuard.js";
7
7
  import { writeTaskProgress } from "../taskProgress.js";
8
8
  import { writeTaskRuntime } from "../taskRuntime.js";
9
- import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, writeSnapshot, } from "./changeCapture.js";
9
+ import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
10
+ import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
11
+ import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, emptyArtifactHygiene, writeSnapshot, extractExternalDirtyFiles, findNewExternalDirtyFiles, buildArtifactManifest, groupChangedFiles, } from "./changeCapture.js";
12
+ import { errorPayload } from "../errors.js";
13
+ import { diagnoseAndroidBuild } from "../tools/androidDoctor.js";
10
14
  const HEARTBEAT_INTERVAL_MS = 2000;
11
15
  const GRACEFUL_KILL_MS = 2000;
12
16
  const MAX_CAPTURE_CHARS = 100_000;
17
+ const ARTIFACT_COLLECTION_TIMEOUT_MS = 60_000;
13
18
  export async function runTask(taskId) {
14
19
  const config = getConfig();
15
20
  const tasksDir = getTasksDir(config);
@@ -29,11 +34,10 @@ export async function runTask(taskId) {
29
34
  let verifyCommands;
30
35
  let timeoutSeconds;
31
36
  try {
32
- verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config);
33
37
  timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
34
38
  }
35
39
  catch (error) {
36
- return failBeforeExecution(taskId, taskDir, errorMessage(error));
40
+ return failBeforeExecution(taskId, taskDir, errorMessage(error), error);
37
41
  }
38
42
  const startedAtMs = Date.now();
39
43
  const deadlineMs = startedAtMs + timeoutSeconds * 1000;
@@ -43,7 +47,30 @@ export async function runTask(taskId) {
43
47
  }
44
48
  catch (error) {
45
49
  const message = `repo_path validation failed: ${errorMessage(error)}`;
46
- return failBeforeExecution(taskId, taskDir, message);
50
+ return failBeforeExecution(taskId, taskDir, message, error);
51
+ }
52
+ try {
53
+ verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
54
+ }
55
+ catch (error) {
56
+ const message = `verification metadata validation failed: ${errorMessage(error)}`;
57
+ return failBeforeExecution(taskId, taskDir, message, error);
58
+ }
59
+ // ── Assessment freshness revalidation before execution ──
60
+ const assessmentId = String(initialStatus.assessment_id || "");
61
+ if (assessmentId) {
62
+ try {
63
+ const preExecSnapshot = captureRepoSnapshot(repoPath);
64
+ const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
65
+ if (!validation.valid) {
66
+ const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
67
+ return failBeforeExecution(taskId, taskDir, message);
68
+ }
69
+ }
70
+ catch (error) {
71
+ const message = `assessment validation error: ${errorMessage(error)}`;
72
+ return failBeforeExecution(taskId, taskDir, message, error);
73
+ }
47
74
  }
48
75
  updateStatus(taskDir, {
49
76
  status: "running",
@@ -57,40 +84,37 @@ export async function runTask(taskId) {
57
84
  setTaskPhase(taskDir, "preparing", null, "Capturing pre-task repository state.");
58
85
  let beforeSnapshot;
59
86
  let beforeWorkspaceSnapshot;
87
+ let externalDirtyBaseline = [];
60
88
  try {
61
89
  beforeSnapshot = captureRepoSnapshot(repoPath);
62
90
  beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : captureRepoSnapshot(wsRoot);
63
91
  writeSnapshot(taskDir, "git-before.json", beforeSnapshot);
64
92
  writeSnapshot(taskDir, "workspace-before.json", beforeWorkspaceSnapshot);
93
+ // Phase 4: Record external dirty files as baseline before task execution
94
+ externalDirtyBaseline = extractExternalDirtyFiles(beforeWorkspaceSnapshot, repoPath, wsRoot);
65
95
  }
66
96
  catch (error) {
67
97
  const message = `Pre-task snapshot failed: ${errorMessage(error)}`;
68
- return failBeforeExecution(taskId, taskDir, message);
98
+ return failBeforeExecution(taskId, taskDir, message, error);
69
99
  }
70
100
  let agentResult = null;
71
101
  let testResult = skippedTest(testCommand, repoPath, "Agent did not complete successfully.");
72
102
  const verifyResults = [];
73
103
  let finalStatus = "failed";
74
104
  let finalError = null;
105
+ let lastCaughtError = null;
75
106
  try {
76
107
  const planFile = resolve(plansDir, planId, "plan.md");
77
108
  if (!existsSync(planFile))
78
109
  throw new Error(`Plan not found: "${planId}". Save the plan first.`);
79
110
  const planContent = readFileSync(planFile, "utf-8");
80
- const agentCmd = guardAgentCommand(agentName, config);
81
- const prompt = sanitizePromptArg(buildExecutionPrompt(planContent, repoPath, testCommand));
82
- const resolvedArgs = agentCmd.args.map((arg) => {
83
- if (arg === "{repo}")
84
- return repoPath;
85
- if (arg === "{prompt}")
86
- return prompt;
87
- return arg;
88
- });
89
- const agentCommandLabel = `${basename(agentCmd.command)} (configured agent command)`;
111
+ const prompt = buildExecutionPrompt(planContent, repoPath, testCommand);
112
+ const invocation = buildAgentInvocation(agentName, repoPath, prompt, config);
113
+ const agentCommandLabel = invocation.commandLabel;
90
114
  setTaskPhase(taskDir, "executing_agent", agentCommandLabel);
91
115
  agentResult = await runManagedProcess({
92
- command: agentCmd.command,
93
- args: resolvedArgs,
116
+ command: invocation.command,
117
+ args: invocation.args,
94
118
  cwd: repoPath,
95
119
  taskDir,
96
120
  statusFile,
@@ -154,19 +178,45 @@ export async function runTask(taskId) {
154
178
  }
155
179
  }
156
180
  catch (error) {
181
+ lastCaughtError = error;
157
182
  finalError = errorMessage(error);
158
183
  }
159
184
  setTaskPhase(taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
185
+ const artifactCollectionStartedAt = new Date().toISOString();
160
186
  let changes;
187
+ let artifactStatus = "collected";
188
+ let artifactCollectionError = null;
189
+ let timeoutHandle;
161
190
  try {
162
- const afterSnapshot = captureRepoSnapshot(repoPath);
163
- writeSnapshot(taskDir, "git-after.json", afterSnapshot);
164
- changes = buildChangeArtifacts(repoPath, beforeSnapshot, afterSnapshot);
191
+ // Phase 5: Wrap artifact collection with a timeout
192
+ const collectionResult = await Promise.race([
193
+ Promise.resolve().then(() => {
194
+ const afterSnapshot = captureRepoSnapshot(repoPath);
195
+ writeSnapshot(taskDir, "git-after.json", afterSnapshot);
196
+ return buildChangeArtifacts(repoPath, beforeSnapshot, afterSnapshot);
197
+ }),
198
+ new Promise((_, reject) => {
199
+ timeoutHandle = setTimeout(() => reject(new Error("Artifact collection timed out")), ARTIFACT_COLLECTION_TIMEOUT_MS);
200
+ }),
201
+ ]);
202
+ if (timeoutHandle)
203
+ clearTimeout(timeoutHandle);
204
+ changes = collectionResult;
165
205
  }
166
206
  catch (error) {
207
+ if (timeoutHandle)
208
+ clearTimeout(timeoutHandle);
209
+ lastCaughtError = error;
210
+ artifactCollectionError = errorMessage(error);
211
+ if (artifactCollectionError.includes("timed out")) {
212
+ artifactStatus = "timeout";
213
+ }
214
+ else {
215
+ artifactStatus = "failed";
216
+ }
167
217
  changes = {
168
218
  changed_files: [],
169
- diff: `(change capture failed: ${errorMessage(error)})\n`,
219
+ diff: `(change capture failed: ${artifactCollectionError})\n`,
170
220
  diff_available: false,
171
221
  diff_truncated: false,
172
222
  diff_size_bytes: 0,
@@ -176,15 +226,50 @@ export async function runTask(taskId) {
176
226
  workspace_dirty_before: beforeSnapshot.workspace_dirty,
177
227
  workspace_dirty_after: beforeSnapshot.workspace_dirty,
178
228
  patch_mode: "hash_only",
179
- unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
229
+ unavailable_reason: `Change capture failed: ${artifactCollectionError}`,
230
+ artifact_hygiene: emptyArtifactHygiene(),
180
231
  };
181
- finalError ||= `Change capture failed: ${errorMessage(error)}`;
182
- finalStatus = "failed";
232
+ // Phase 5: Write partial_result.md when artifact collection fails
233
+ writeFileSync(join(taskDir, "partial_result.md"), [
234
+ "# PatchWarden Partial Result",
235
+ "",
236
+ `Task: ${taskId}`,
237
+ "",
238
+ "## Artifact Collection Status",
239
+ artifactStatus,
240
+ "",
241
+ "## Error",
242
+ artifactCollectionError,
243
+ "",
244
+ "## Agent Status",
245
+ agentResult ? `Exit code: ${agentResult.exitCode}` : "Agent did not run",
246
+ "",
247
+ "## Verification Status",
248
+ verifyResults.length > 0 ? verifyResults.map((r) => `- ${r.command}: exit ${r.exitCode}`).join("\n") : "No verification ran",
249
+ "",
250
+ "## Note",
251
+ "Artifact collection did not complete. The task result may be incomplete. Review stdout.log, stderr.log, and verify.json for details.",
252
+ "",
253
+ ].join("\n"), "utf-8");
254
+ // Don't override test failure with artifact failure
255
+ if (finalStatus === "done") {
256
+ finalError = `Change capture failed: ${artifactCollectionError}`;
257
+ finalStatus = "failed";
258
+ }
259
+ else {
260
+ finalError ||= `Change capture failed: ${artifactCollectionError}`;
261
+ }
183
262
  }
263
+ const artifactCollectionFinishedAt = new Date().toISOString();
184
264
  let outOfScopeChanges = [];
265
+ let preexistingExternalDirty = externalDirtyBaseline;
266
+ let newOutOfScopeChanges = [];
185
267
  try {
186
268
  const afterWorkspaceSnapshot = repoPath === wsRoot ? captureRepoSnapshot(repoPath) : captureRepoSnapshot(wsRoot);
187
269
  writeSnapshot(taskDir, "workspace-after.json", afterWorkspaceSnapshot);
270
+ const allExternalDirty = extractExternalDirtyFiles(afterWorkspaceSnapshot, repoPath, wsRoot);
271
+ // Phase 4: Only NEW external dirty files (not in baseline) are scope violations
272
+ newOutOfScopeChanges = findNewExternalDirtyFiles(externalDirtyBaseline, allExternalDirty);
188
273
  outOfScopeChanges = compareSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
189
274
  .filter((file) => !isPathInside(resolve(wsRoot, file.path), repoPath) ||
190
275
  Boolean(file.old_path && !isPathInside(resolve(wsRoot, file.old_path), repoPath)));
@@ -194,15 +279,20 @@ export async function runTask(taskId) {
194
279
  if (finalStatus === "done")
195
280
  finalStatus = "failed";
196
281
  }
197
- if (outOfScopeChanges.length > 0) {
282
+ // Phase 4: Pre-existing external dirty files are warnings, not failures
283
+ const preexistingWarnings = preexistingExternalDirty.length > 0
284
+ ? [`Pre-existing external dirty files (not caused by this task): ${preexistingExternalDirty.length} file(s)`]
285
+ : [];
286
+ if (newOutOfScopeChanges.length > 0) {
198
287
  finalStatus = "failed_scope_violation";
199
- finalError = `Detected ${outOfScopeChanges.length} change(s) outside resolved_repo_path.`;
288
+ finalError = `Detected ${newOutOfScopeChanges.length} new change(s) outside resolved_repo_path during task execution.`;
200
289
  writeFileSync(join(taskDir, "rollback-plan.json"), JSON.stringify({
201
290
  task_id: taskId,
202
291
  status: "review_required",
203
292
  automatic_rollback_performed: false,
204
293
  warning: "Review ownership and concurrent edits before rollback. PatchWarden did not modify or restore these files.",
205
- out_of_scope_changes: outOfScopeChanges,
294
+ out_of_scope_changes: newOutOfScopeChanges,
295
+ preexisting_external_dirty_files: preexistingExternalDirty,
206
296
  }, null, 2), "utf-8");
207
297
  writeFileSync(join(taskDir, "rollback_scope_violation_plan.md"), [
208
298
  "# Scope Violation Rollback Plan",
@@ -211,8 +301,8 @@ export async function runTask(taskId) {
211
301
  "",
212
302
  "PatchWarden did not automatically roll back any file. Review concurrent or user-owned edits before acting.",
213
303
  "",
214
- "## Out-of-scope files only",
215
- ...outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`),
304
+ "## New out-of-scope files (caused by this task)",
305
+ ...newOutOfScopeChanges.map((file) => `- ${file.change}: ${file.path}`),
216
306
  "",
217
307
  ].join("\n"), "utf-8");
218
308
  }
@@ -223,6 +313,10 @@ export async function runTask(taskId) {
223
313
  writeFileSync(join(taskDir, "git.diff"), changes.diff, "utf-8");
224
314
  writeFileSync(join(taskDir, "diff.patch"), changes.diff, "utf-8");
225
315
  writeFileSync(join(taskDir, "changed-files.json"), JSON.stringify(changes, null, 2), "utf-8");
316
+ // Phase 6: Generate artifact_manifest.json and group changed files
317
+ const artifactManifest = buildArtifactManifest(changes.changed_files, repoPath, taskId);
318
+ writeFileSync(join(taskDir, "artifact_manifest.json"), JSON.stringify(artifactManifest, null, 2), "utf-8");
319
+ const changedFileGroups = groupChangedFiles(changes.changed_files);
226
320
  writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
227
321
  task_id: taskId,
228
322
  additions: changes.additions,
@@ -231,7 +325,10 @@ export async function runTask(taskId) {
231
325
  }, null, 2), "utf-8");
232
326
  writeFileSync(join(taskDir, "test.log"), buildTestLog(testResult), "utf-8");
233
327
  const verifyJson = buildVerifyJson(verifyCommands, verifyResults, repoPath);
234
- if (outOfScopeChanges.length > 0) {
328
+ // Phase 4: Use newOutOfScopeChanges (not outOfScopeChanges) for verify_status
329
+ // to stay consistent with finalStatus. Pre-existing external dirty files
330
+ // that didn't change during the task should NOT cause verify_status to fail.
331
+ if (newOutOfScopeChanges.length > 0) {
235
332
  verifyJson.status = "failed";
236
333
  verifyJson.failure_reason = "scope_violation";
237
334
  }
@@ -245,6 +342,20 @@ export async function runTask(taskId) {
245
342
  finalStatus = "failed";
246
343
  const finalPhase = finalStatus === "done" ? "completed" : finalStatus;
247
344
  const followup = buildFailureFollowup(finalStatus, finalError, verifyJson.commands);
345
+ // Phase 7: Run Android build environment diagnostics if android_app exists
346
+ const androidDiagnostic = diagnoseAndroidBuild(repoPath);
347
+ let androidWarning = null;
348
+ if (androidDiagnostic.status !== "skip") {
349
+ if (androidDiagnostic.status === "fail") {
350
+ androidWarning = "Android project exists, APK not built because Android SDK is missing.";
351
+ }
352
+ else if (androidDiagnostic.status === "warn") {
353
+ const apkCheck = androidDiagnostic.checks.find((c) => c.check === "APK output path");
354
+ if (apkCheck && apkCheck.status !== "ok") {
355
+ androidWarning = `Android build environment has warnings: ${apkCheck.reason}`;
356
+ }
357
+ }
358
+ }
248
359
  const resultMd = buildResultMarkdown({
249
360
  taskId,
250
361
  planId,
@@ -256,6 +367,12 @@ export async function runTask(taskId) {
256
367
  verify: verifyJson,
257
368
  changes,
258
369
  outOfScopeChanges,
370
+ artifactStatus,
371
+ artifactManifest,
372
+ changedFileGroups,
373
+ androidDiagnostic,
374
+ androidWarning,
375
+ preexistingWarnings,
259
376
  });
260
377
  writeFileSync(join(taskDir, "result.md"), resultMd, "utf-8");
261
378
  writeFileSync(join(taskDir, "result.json"), JSON.stringify({
@@ -270,7 +387,26 @@ export async function runTask(taskId) {
270
387
  change_policy: changePolicy,
271
388
  summary: finalError || "Agent execution and configured verification completed successfully.",
272
389
  changed_files: changes.changed_files,
390
+ changed_file_groups: {
391
+ source_changes: changedFileGroups.source_changes.length,
392
+ docs_changes: changedFileGroups.docs_changes.length,
393
+ config_changes: changedFileGroups.config_changes.length,
394
+ test_changes: changedFileGroups.test_changes.length,
395
+ release_artifacts: changedFileGroups.release_artifacts.length,
396
+ runtime_generated_files: changedFileGroups.runtime_generated_files.length,
397
+ },
398
+ artifact_hygiene: changes.artifact_hygiene,
399
+ artifact_status: artifactStatus,
400
+ artifact_collection_error: artifactCollectionError,
401
+ artifact_collection_started_at: artifactCollectionStartedAt,
402
+ artifact_collection_finished_at: artifactCollectionFinishedAt,
403
+ artifact_manifest: artifactManifest,
273
404
  out_of_scope_changes: outOfScopeChanges,
405
+ new_out_of_scope_changes: newOutOfScopeChanges,
406
+ preexisting_external_dirty_files: preexistingExternalDirty,
407
+ target_repo_status: changes.workspace_dirty_after ? "dirty" : "clean",
408
+ workspace_status: changes.workspace_dirty_after ? "dirty" : "clean",
409
+ android_diagnostic: androidDiagnostic,
274
410
  verify_status: verifyJson.status,
275
411
  verify_commands: verifyJson.commands,
276
412
  commands_run: verifyJson.commands.map((command) => ({
@@ -281,12 +417,15 @@ export async function runTask(taskId) {
281
417
  commands_observed: [],
282
418
  verify: verifyJson,
283
419
  artifacts: [
284
- "result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json",
420
+ "result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json", "artifact_manifest.json",
285
421
  ...(outOfScopeChanges.length > 0 ? ["rollback_scope_violation_plan.md", "rollback-plan.json"] : []),
422
+ ...(artifactStatus !== "collected" ? ["partial_result.md"] : []),
286
423
  ],
287
424
  warnings: [
288
425
  ...beforeSnapshot.warnings,
289
426
  ...(changes.diff_truncated ? ["diff.patch was truncated; changed-files.json retains file evidence."] : []),
427
+ ...preexistingWarnings,
428
+ ...(androidWarning ? [androidWarning] : []),
290
429
  ],
291
430
  errors: finalError ? [finalError] : [],
292
431
  known_issues: finalError ? [finalError] : [],
@@ -298,8 +437,14 @@ export async function runTask(taskId) {
298
437
  ? ["Review get_task_summary and audit_task before accepting the work."]
299
438
  : ["Resolve the reported failure before accepting the work."],
300
439
  }, null, 2), "utf-8");
301
- if (finalError)
302
- writeFileSync(join(taskDir, "error.log"), finalError, "utf-8");
440
+ if (finalError) {
441
+ // Phase 9: Preserve PatchWardenError structured info in error.log
442
+ const structuredError = errorPayload(lastCaughtError);
443
+ writeFileSync(join(taskDir, "error.log"), JSON.stringify({
444
+ summary: finalError,
445
+ ...structuredError,
446
+ }, null, 2), "utf-8");
447
+ }
303
448
  const finishedAt = new Date().toISOString();
304
449
  updateStatus(taskDir, {
305
450
  status: finalStatus,
@@ -309,7 +454,14 @@ export async function runTask(taskId) {
309
454
  finished_at: finishedAt,
310
455
  error: finalError,
311
456
  changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
457
+ artifact_hygiene_counts: changes.artifact_hygiene.counts,
458
+ artifact_status: artifactStatus,
459
+ artifact_collection_error: artifactCollectionError,
460
+ artifact_collection_started_at: artifactCollectionStartedAt,
461
+ artifact_collection_finished_at: artifactCollectionFinishedAt,
312
462
  out_of_scope_changes: outOfScopeChanges,
463
+ new_out_of_scope_changes: newOutOfScopeChanges,
464
+ preexisting_external_dirty_files: preexistingExternalDirty,
313
465
  verify_status: verifyJson.status,
314
466
  verify_commands: verifyCommands,
315
467
  diff_available: changes.diff_available,
@@ -403,6 +555,8 @@ async function runManagedProcess(options) {
403
555
  let spawnError = null;
404
556
  let terminationReason = null;
405
557
  let forceTimer = null;
558
+ let fallbackTimer = null;
559
+ let childExitFinish = null;
406
560
  let terminationStarted = false;
407
561
  const heartbeat = () => {
408
562
  const now = new Date().toISOString();
@@ -442,6 +596,22 @@ async function runManagedProcess(options) {
442
596
  gracefulKill(child);
443
597
  forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
444
598
  }
599
+ // Fallback: if neither close nor exit fires within 10s after taskkill,
600
+ // force-resolve to prevent the runner from hanging indefinitely.
601
+ // Only starts AFTER termination is requested — normal long tasks are unaffected.
602
+ fallbackTimer = setTimeout(() => {
603
+ try {
604
+ forceKill(child);
605
+ }
606
+ catch { }
607
+ try {
608
+ child.kill("SIGKILL");
609
+ }
610
+ catch { }
611
+ // Resolve the exit promise via the shared finish handle
612
+ if (childExitFinish)
613
+ childExitFinish(null);
614
+ }, 10000);
445
615
  };
446
616
  child.stdout?.on("data", (chunk) => {
447
617
  const text = chunk.toString("utf-8");
@@ -461,9 +631,19 @@ async function runManagedProcess(options) {
461
631
  if (settled)
462
632
  return;
463
633
  settled = true;
634
+ if (fallbackTimer)
635
+ clearTimeout(fallbackTimer);
636
+ childExitFinish = null;
464
637
  resolveExit(code);
465
638
  };
639
+ childExitFinish = finish;
466
640
  child.once("close", (code) => finish(code));
641
+ child.once("exit", (code) => {
642
+ // On Windows, "close" can lag behind "exit" when stdio pipes drain
643
+ // after taskkill. Resolve on exit to avoid hanging the runner, but
644
+ // still let "close" fire if it arrives first.
645
+ finish(code);
646
+ });
467
647
  child.once("error", (error) => {
468
648
  spawnError = error.message;
469
649
  finish(null);
@@ -472,8 +652,8 @@ async function runManagedProcess(options) {
472
652
  clearInterval(heartbeatTimer);
473
653
  if (forceTimer)
474
654
  clearTimeout(forceTimer);
475
- stdoutStream?.end();
476
- stderrStream?.end();
655
+ stdoutStream?.destroy();
656
+ stderrStream?.destroy();
477
657
  writeTaskRuntime(options.taskDir, {
478
658
  phase: options.phase,
479
659
  last_heartbeat_at: new Date().toISOString(),
@@ -485,7 +665,7 @@ async function runManagedProcess(options) {
485
665
  }
486
666
  async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile, deadlineMs) {
487
667
  const config = getConfig();
488
- const trusted = guardTestCommand(testCommand, config);
668
+ const trusted = guardTestCommand(testCommand, config, repoPath);
489
669
  const startedAtMs = Date.now();
490
670
  const startedAt = new Date(startedAtMs).toISOString();
491
671
  const parts = trusted.split(/\s+/).filter(Boolean);
@@ -518,28 +698,6 @@ async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile,
518
698
  duration_ms: finishedAtMs - startedAtMs,
519
699
  };
520
700
  }
521
- function buildExecutionPrompt(plan, repoPath, testCommand) {
522
- let prompt = `You are executing a pre-written plan in a local repository.
523
-
524
- ## Repository
525
- ${repoPath}
526
-
527
- ## Plan
528
- ${plan}
529
-
530
- ## Instructions
531
- 1. Read the plan carefully.
532
- 2. Implement the changes in this repository only.
533
- 3. Do NOT modify files outside this repository.
534
- 4. Do NOT commit or push changes.
535
- 5. After implementing, describe what you changed.
536
- 6. Output a summary with what was done, files modified, and issues encountered.
537
- `;
538
- if (testCommand) {
539
- prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
540
- }
541
- return prompt;
542
- }
543
701
  function buildTestLog(result) {
544
702
  if (result.skipped)
545
703
  return `${result.command || "(no test command)"}\nExit code: not run\n${result.stderr}\n`;
@@ -560,6 +718,40 @@ function buildResultMarkdown(input) {
560
718
  const outOfScope = input.outOfScopeChanges.length
561
719
  ? input.outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`).join("\n")
562
720
  : "(none)";
721
+ // Phase 6: Artifact manifest summary
722
+ const artifactCount = input.artifactManifest?.artifacts.length || 0;
723
+ const artifactLines = artifactCount > 0
724
+ ? input.artifactManifest.artifacts.map((a) => `- ${a.type}: ${a.path} (${a.size} bytes, sha256: ${a.sha256.slice(0, 16)}...)`).join("\n")
725
+ : "(no release artifacts)";
726
+ // Phase 6: Changed file group summary
727
+ const groupLines = input.changedFileGroups
728
+ ? [
729
+ `- source_changes: ${input.changedFileGroups.source_changes.length}`,
730
+ `- docs_changes: ${input.changedFileGroups.docs_changes.length}`,
731
+ `- config_changes: ${input.changedFileGroups.config_changes.length}`,
732
+ `- test_changes: ${input.changedFileGroups.test_changes.length}`,
733
+ `- release_artifacts: ${input.changedFileGroups.release_artifacts.length}`,
734
+ `- runtime_generated_files: ${input.changedFileGroups.runtime_generated_files.length}`,
735
+ ].join("\n")
736
+ : "";
737
+ // Phase 7: Android diagnostic summary
738
+ let androidSection = "";
739
+ if (input.androidDiagnostic && input.androidDiagnostic.status !== "skip") {
740
+ const checkLines = (input.androidDiagnostic.checks || [])
741
+ .map((c) => `- [${c.status}] ${c.check}: ${c.reason}`)
742
+ .join("\n");
743
+ androidSection = [
744
+ "## Android Build Environment",
745
+ `Status: ${input.androidDiagnostic.status}`,
746
+ ...(input.androidWarning ? [`Warning: ${input.androidWarning}`] : []),
747
+ checkLines,
748
+ "",
749
+ ].join("\n");
750
+ }
751
+ // Phase 4: Pre-existing warnings
752
+ const preexistingSection = input.preexistingWarnings && input.preexistingWarnings.length > 0
753
+ ? ["## Pre-existing Warnings", ...input.preexistingWarnings, ""].join("\n")
754
+ : "";
563
755
  return [
564
756
  "# PatchWarden Task Result",
565
757
  "",
@@ -578,6 +770,12 @@ function buildResultMarkdown(input) {
578
770
  "## Files changed",
579
771
  changed,
580
772
  "",
773
+ "## Changed file groups",
774
+ groupLines,
775
+ "",
776
+ "## Release artifacts",
777
+ artifactLines,
778
+ "",
581
779
  "## Verification",
582
780
  `- diff_available: ${input.changes.diff_available}`,
583
781
  `- diff_truncated: ${input.changes.diff_truncated}`,
@@ -586,10 +784,13 @@ function buildResultMarkdown(input) {
586
784
  `- verify_status: ${input.verify.status}`,
587
785
  `- verify_commands: ${input.verify.commands.length}/${input.verify.requested_commands.length} executed`,
588
786
  `- out_of_scope_changes: ${input.outOfScopeChanges.length}`,
787
+ `- artifact_status: ${input.artifactStatus || "collected"}`,
589
788
  "",
590
789
  "## Out-of-scope changes",
591
790
  outOfScope,
592
791
  "",
792
+ preexistingSection,
793
+ androidSection,
593
794
  "## Summary",
594
795
  input.error || "Agent execution and configured verification completed successfully.",
595
796
  "",
@@ -671,13 +872,13 @@ function buildVerifyLog(commands) {
671
872
  entry.stderr_tail || "(empty)",
672
873
  ].join("\n")).join("\n\n");
673
874
  }
674
- function normalizeVerifyCommands(value, legacyTestCommand, config) {
875
+ function normalizeVerifyCommands(value, legacyTestCommand, config, repoPath) {
675
876
  if (value !== undefined && !Array.isArray(value)) {
676
877
  throw new Error("Invalid task verify_commands metadata; expected an array.");
677
878
  }
678
879
  return [...new Set([
679
- ...(value || []).map((command) => guardTestCommand(String(command), config)),
680
- ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config)] : []),
880
+ ...(value || []).map((command) => guardTestCommand(String(command), config, repoPath)),
881
+ ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config, repoPath)] : []),
681
882
  ])];
682
883
  }
683
884
  function summarizeOutput(value) {
@@ -695,8 +896,13 @@ function normalizeTimeout(value, config) {
695
896
  }
696
897
  return timeout;
697
898
  }
698
- function failBeforeExecution(taskId, taskDir, message) {
699
- writeFileSync(join(taskDir, "error.log"), message, "utf-8");
899
+ function failBeforeExecution(taskId, taskDir, message, caughtError) {
900
+ // Phase 9: Preserve PatchWardenError structured info in error.log
901
+ const structuredError = caughtError ? errorPayload(caughtError) : { error: message };
902
+ writeFileSync(join(taskDir, "error.log"), JSON.stringify({
903
+ summary: message,
904
+ ...structuredError,
905
+ }, null, 2), "utf-8");
700
906
  const current = readStatus(join(taskDir, "status.json"));
701
907
  const now = new Date().toISOString();
702
908
  const verify = {
@@ -0,0 +1,21 @@
1
+ export interface SimpleProcessOptions {
2
+ command: string;
3
+ args: string[];
4
+ cwd: string;
5
+ timeoutMs: number;
6
+ maxStdoutBytes?: number;
7
+ maxStderrBytes?: number;
8
+ stdoutPath?: string;
9
+ stderrPath?: string;
10
+ }
11
+ export interface SimpleProcessResult {
12
+ exitCode: number | null;
13
+ stdout: string;
14
+ stderr: string;
15
+ spawnError: string | null;
16
+ timedOut: boolean;
17
+ stdoutTruncated: boolean;
18
+ stderrTruncated: boolean;
19
+ }
20
+ export declare function runSimpleProcessSync(options: SimpleProcessOptions): SimpleProcessResult;
21
+ export declare function runSimpleProcess(options: SimpleProcessOptions): Promise<SimpleProcessResult>;