patchwarden 0.6.0 → 0.6.4

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 (134) hide show
  1. package/PatchWarden-Control-Tray.cmd +11 -0
  2. package/PatchWarden-Control.cmd +6 -0
  3. package/PatchWarden-Desktop.cmd +5 -0
  4. package/PatchWarden.cmd +1 -1
  5. package/README.en.md +106 -18
  6. package/README.md +30 -19
  7. package/Restart-PatchWarden-Control.cmd +6 -0
  8. package/Stop-PatchWarden.cmd +11 -0
  9. package/dist/controlCenter.d.ts +14 -0
  10. package/dist/controlCenter.js +2002 -0
  11. package/dist/doctor.js +4 -4
  12. package/dist/logging.d.ts +52 -0
  13. package/dist/logging.js +123 -0
  14. package/dist/runner/changeCapture.d.ts +41 -0
  15. package/dist/runner/changeCapture.js +171 -1
  16. package/dist/runner/runTask.js +204 -24
  17. package/dist/smoke-test.js +8 -8
  18. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  19. package/dist/test/unit/android-doctor.test.js +118 -0
  20. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  21. package/dist/test/unit/chinese-path.test.js +91 -0
  22. package/dist/test/unit/command-guard.test.d.ts +1 -0
  23. package/dist/test/unit/command-guard.test.js +160 -0
  24. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  25. package/dist/test/unit/direct-guards.test.js +213 -0
  26. package/dist/test/unit/logging.test.d.ts +1 -0
  27. package/dist/test/unit/logging.test.js +275 -0
  28. package/dist/test/unit/path-guard.test.d.ts +1 -0
  29. package/dist/test/unit/path-guard.test.js +109 -0
  30. package/dist/test/unit/safe-status.test.d.ts +1 -0
  31. package/dist/test/unit/safe-status.test.js +165 -0
  32. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  33. package/dist/test/unit/sensitive-guard.test.js +104 -0
  34. package/dist/test/unit/sync-file.test.d.ts +1 -0
  35. package/dist/test/unit/sync-file.test.js +154 -0
  36. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  37. package/dist/test/unit/watcher-status.test.js +169 -0
  38. package/dist/tools/androidDoctor.d.ts +38 -0
  39. package/dist/tools/androidDoctor.js +391 -0
  40. package/dist/tools/auditTask.js +11 -5
  41. package/dist/tools/getTaskSummary.d.ts +3 -0
  42. package/dist/tools/getTaskSummary.js +15 -1
  43. package/dist/tools/healthCheck.d.ts +5 -0
  44. package/dist/tools/healthCheck.js +21 -0
  45. package/dist/tools/registry.js +53 -0
  46. package/dist/tools/safeStatus.d.ts +19 -0
  47. package/dist/tools/safeStatus.js +72 -0
  48. package/dist/tools/syncFile.d.ts +18 -0
  49. package/dist/tools/syncFile.js +65 -0
  50. package/dist/tools/taskOutputs.d.ts +2 -2
  51. package/dist/tools/toolCatalog.d.ts +2 -2
  52. package/dist/tools/toolCatalog.js +2 -0
  53. package/dist/version.d.ts +2 -2
  54. package/dist/version.js +2 -2
  55. package/dist/watcherStatus.d.ts +1 -0
  56. package/dist/watcherStatus.js +96 -4
  57. package/docs/control-center/README.md +33 -0
  58. package/docs/control-center/control-center-daily-driver.md +211 -0
  59. package/docs/control-center/control-center-mvp.md +205 -0
  60. package/docs/control-center/control-center-phase2.md +159 -0
  61. package/docs/demo.md +3 -0
  62. package/docs/performance-notes.md +55 -0
  63. package/docs/release-v0.6.1.md +75 -0
  64. package/docs/release-v0.6.4.md +45 -0
  65. package/examples/openai-tunnel/README.md +5 -5
  66. package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
  67. package/package.json +23 -14
  68. package/scripts/README.md +47 -0
  69. package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
  70. package/scripts/checks/control-center-smoke.js +1098 -0
  71. package/scripts/{control-smoke.js → checks/control-smoke.js} +17 -3
  72. package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +1 -1
  73. package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +12 -4
  74. package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +347 -13
  75. package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +32 -9
  76. package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +14 -9
  77. package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
  78. package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
  79. package/scripts/checks/unit-tests.js +36 -0
  80. package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +11 -5
  81. package/scripts/control/control-center-tray.ps1 +281 -0
  82. package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
  83. package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
  84. package/scripts/control/restart-control-center.ps1 +173 -0
  85. package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
  86. package/scripts/control/start-control-center.ps1 +263 -0
  87. package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
  88. package/scripts/control/stop-patchwarden.ps1 +114 -0
  89. package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
  90. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
  91. package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
  92. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
  93. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
  94. package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
  95. package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
  96. package/scripts/release/pack-clean.js +305 -0
  97. package/src/controlCenter.ts +2152 -0
  98. package/src/doctor.ts +5 -5
  99. package/src/logging.ts +152 -0
  100. package/src/runner/changeCapture.ts +212 -1
  101. package/src/runner/runTask.ts +220 -22
  102. package/src/smoke-test.ts +5 -5
  103. package/src/test/unit/android-doctor.test.ts +158 -0
  104. package/src/test/unit/chinese-path.test.ts +106 -0
  105. package/src/test/unit/command-guard.test.ts +221 -0
  106. package/src/test/unit/direct-guards.test.ts +297 -0
  107. package/src/test/unit/logging.test.ts +325 -0
  108. package/src/test/unit/path-guard.test.ts +150 -0
  109. package/src/test/unit/safe-status.test.ts +187 -0
  110. package/src/test/unit/sensitive-guard.test.ts +124 -0
  111. package/src/test/unit/sync-file.test.ts +231 -0
  112. package/src/test/unit/watcher-status.test.ts +190 -0
  113. package/src/tools/androidDoctor.ts +424 -0
  114. package/src/tools/auditTask.ts +11 -5
  115. package/src/tools/getTaskSummary.ts +22 -1
  116. package/src/tools/healthCheck.ts +22 -0
  117. package/src/tools/registry.ts +63 -0
  118. package/src/tools/safeStatus.ts +96 -0
  119. package/src/tools/syncFile.ts +122 -0
  120. package/src/tools/toolCatalog.ts +2 -0
  121. package/src/version.ts +2 -2
  122. package/src/watcherStatus.ts +101 -4
  123. package/ui/colors_and_type.css +141 -0
  124. package/ui/pages/audit.html +743 -0
  125. package/ui/pages/dashboard.html +1154 -0
  126. package/ui/pages/direct-sessions.html +652 -0
  127. package/ui/pages/logs.html +502 -0
  128. package/ui/pages/task-detail.html +1229 -0
  129. package/ui/pages/tasks.html +702 -0
  130. package/ui/pages/workspace.html +947 -0
  131. package/ui/partials/project-shell.html +362 -0
  132. package/ui/vendor/lucide.js +12 -0
  133. package/ui/vendor/tailwindcss-browser.js +947 -0
  134. package/scripts/pack-clean.js +0 -141
@@ -23,13 +23,22 @@ import {
23
23
  compareSnapshots,
24
24
  emptyArtifactHygiene,
25
25
  writeSnapshot,
26
+ extractExternalDirtyFiles,
27
+ findNewExternalDirtyFiles,
28
+ buildArtifactManifest,
29
+ groupChangedFiles,
26
30
  type ChangedFile,
27
31
  type ChangeArtifacts,
32
+ type ExternalDirtyFile,
33
+ type ArtifactManifest,
28
34
  } from "./changeCapture.js";
35
+ import { PatchWardenError, errorPayload } from "../errors.js";
36
+ import { diagnoseAndroidBuild } from "../tools/androidDoctor.js";
29
37
 
30
38
  const HEARTBEAT_INTERVAL_MS = 2000;
31
39
  const GRACEFUL_KILL_MS = 2000;
32
40
  const MAX_CAPTURE_CHARS = 100_000;
41
+ const ARTIFACT_COLLECTION_TIMEOUT_MS = 60_000;
33
42
 
34
43
  interface TaskRunResult {
35
44
  task_id: string;
@@ -97,7 +106,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
97
106
  try {
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,13 +116,13 @@ 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);
111
120
  }
112
121
  try {
113
122
  verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
114
123
  } catch (error) {
115
124
  const message = `verification metadata validation failed: ${errorMessage(error)}`;
116
- return failBeforeExecution(taskId, taskDir, message);
125
+ return failBeforeExecution(taskId, taskDir, message, error);
117
126
  }
118
127
 
119
128
  // ── Assessment freshness revalidation before execution ──
@@ -128,7 +137,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
128
137
  }
129
138
  } catch (error) {
130
139
  const message = `assessment validation error: ${errorMessage(error)}`;
131
- return failBeforeExecution(taskId, taskDir, message);
140
+ return failBeforeExecution(taskId, taskDir, message, error);
132
141
  }
133
142
  }
134
143
 
@@ -145,14 +154,17 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
145
154
 
146
155
  let beforeSnapshot;
147
156
  let beforeWorkspaceSnapshot;
157
+ let externalDirtyBaseline: ExternalDirtyFile[] = [];
148
158
  try {
149
159
  beforeSnapshot = captureRepoSnapshot(repoPath);
150
160
  beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : captureRepoSnapshot(wsRoot);
151
161
  writeSnapshot(taskDir, "git-before.json", beforeSnapshot);
152
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);
153
165
  } catch (error) {
154
166
  const message = `Pre-task snapshot failed: ${errorMessage(error)}`;
155
- return failBeforeExecution(taskId, taskDir, message);
167
+ return failBeforeExecution(taskId, taskDir, message, error);
156
168
  }
157
169
 
158
170
  let agentResult: ManagedProcessResult | null = null;
@@ -160,6 +172,7 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
160
172
  const verifyResults: TestExecutionResult[] = [];
161
173
  let finalStatus: TaskStatus = "failed";
162
174
  let finalError: string | null = null;
175
+ let lastCaughtError: unknown = null;
163
176
 
164
177
  try {
165
178
  const planFile = resolve(plansDir, planId, "plan.md");
@@ -226,19 +239,42 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
226
239
  finalStatus = "done";
227
240
  }
228
241
  } catch (error) {
242
+ lastCaughtError = error;
229
243
  finalError = errorMessage(error);
230
244
  }
231
245
 
232
246
  setTaskPhase(taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
247
+ const artifactCollectionStartedAt = new Date().toISOString();
233
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;
234
252
  try {
235
- const afterSnapshot = captureRepoSnapshot(repoPath);
236
- writeSnapshot(taskDir, "git-after.json", afterSnapshot);
237
- 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;
238
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
+ }
239
275
  changes = {
240
276
  changed_files: [],
241
- diff: `(change capture failed: ${errorMessage(error)})\n`,
277
+ diff: `(change capture failed: ${artifactCollectionError})\n`,
242
278
  diff_available: false,
243
279
  diff_truncated: false,
244
280
  diff_size_bytes: 0,
@@ -248,17 +284,50 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
248
284
  workspace_dirty_before: beforeSnapshot.workspace_dirty,
249
285
  workspace_dirty_after: beforeSnapshot.workspace_dirty,
250
286
  patch_mode: "hash_only",
251
- unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
287
+ unavailable_reason: `Change capture failed: ${artifactCollectionError}`,
252
288
  artifact_hygiene: emptyArtifactHygiene(),
253
289
  };
254
- finalError ||= `Change capture failed: ${errorMessage(error)}`;
255
- 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
+ }
256
319
  }
320
+ const artifactCollectionFinishedAt = new Date().toISOString();
257
321
 
258
322
  let outOfScopeChanges: ChangedFile[] = [];
323
+ let preexistingExternalDirty: ExternalDirtyFile[] = externalDirtyBaseline;
324
+ let newOutOfScopeChanges: ExternalDirtyFile[] = [];
259
325
  try {
260
326
  const afterWorkspaceSnapshot = repoPath === wsRoot ? captureRepoSnapshot(repoPath) : captureRepoSnapshot(wsRoot);
261
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);
262
331
  outOfScopeChanges = compareSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
263
332
  .filter((file) =>
264
333
  !isPathInside(resolve(wsRoot, file.path), repoPath) ||
@@ -269,15 +338,21 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
269
338
  if (finalStatus === "done") finalStatus = "failed";
270
339
  }
271
340
 
272
- 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) {
273
347
  finalStatus = "failed_scope_violation";
274
- 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.`;
275
349
  writeFileSync(join(taskDir, "rollback-plan.json"), JSON.stringify({
276
350
  task_id: taskId,
277
351
  status: "review_required",
278
352
  automatic_rollback_performed: false,
279
353
  warning: "Review ownership and concurrent edits before rollback. PatchWarden did not modify or restore these files.",
280
- out_of_scope_changes: outOfScopeChanges,
354
+ out_of_scope_changes: newOutOfScopeChanges,
355
+ preexisting_external_dirty_files: preexistingExternalDirty,
281
356
  }, null, 2), "utf-8");
282
357
  writeFileSync(join(taskDir, "rollback_scope_violation_plan.md"), [
283
358
  "# Scope Violation Rollback Plan",
@@ -286,8 +361,8 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
286
361
  "",
287
362
  "PatchWarden did not automatically roll back any file. Review concurrent or user-owned edits before acting.",
288
363
  "",
289
- "## Out-of-scope files only",
290
- ...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}`),
291
366
  "",
292
367
  ].join("\n"), "utf-8");
293
368
  } else if (changePolicy === "no_changes" && changes.changed_files.length > 0) {
@@ -298,6 +373,12 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
298
373
  writeFileSync(join(taskDir, "git.diff"), changes.diff, "utf-8");
299
374
  writeFileSync(join(taskDir, "diff.patch"), changes.diff, "utf-8");
300
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
+
301
382
  writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
302
383
  task_id: taskId,
303
384
  additions: changes.additions,
@@ -306,7 +387,10 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
306
387
  }, null, 2), "utf-8");
307
388
  writeFileSync(join(taskDir, "test.log"), buildTestLog(testResult), "utf-8");
308
389
  const verifyJson = buildVerifyJson(verifyCommands, verifyResults, repoPath);
309
- 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) {
310
394
  verifyJson.status = "failed";
311
395
  verifyJson.failure_reason = "scope_violation";
312
396
  } else if (finalStatus === "failed_policy_violation") {
@@ -319,6 +403,21 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
319
403
  if (!["canceled", "done", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(finalStatus)) finalStatus = "failed";
320
404
  const finalPhase: TaskPhase = finalStatus === "done" ? "completed" : finalStatus;
321
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
+
322
421
  const resultMd = buildResultMarkdown({
323
422
  taskId,
324
423
  planId,
@@ -330,6 +429,12 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
330
429
  verify: verifyJson,
331
430
  changes,
332
431
  outOfScopeChanges,
432
+ artifactStatus,
433
+ artifactManifest,
434
+ changedFileGroups,
435
+ androidDiagnostic,
436
+ androidWarning,
437
+ preexistingWarnings,
333
438
  });
334
439
  writeFileSync(join(taskDir, "result.md"), resultMd, "utf-8");
335
440
  writeFileSync(join(taskDir, "result.json"), JSON.stringify({
@@ -344,8 +449,26 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
344
449
  change_policy: changePolicy,
345
450
  summary: finalError || "Agent execution and configured verification completed successfully.",
346
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
+ },
347
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,
348
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,
349
472
  verify_status: verifyJson.status,
350
473
  verify_commands: verifyJson.commands,
351
474
  commands_run: verifyJson.commands.map((command) => ({
@@ -356,12 +479,15 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
356
479
  commands_observed: [],
357
480
  verify: verifyJson,
358
481
  artifacts: [
359
- "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",
360
483
  ...(outOfScopeChanges.length > 0 ? ["rollback_scope_violation_plan.md", "rollback-plan.json"] : []),
484
+ ...(artifactStatus !== "collected" ? ["partial_result.md"] : []),
361
485
  ],
362
486
  warnings: [
363
487
  ...beforeSnapshot.warnings,
364
488
  ...(changes.diff_truncated ? ["diff.patch was truncated; changed-files.json retains file evidence."] : []),
489
+ ...preexistingWarnings,
490
+ ...(androidWarning ? [androidWarning] : []),
365
491
  ],
366
492
  errors: finalError ? [finalError] : [],
367
493
  known_issues: finalError ? [finalError] : [],
@@ -373,7 +499,14 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
373
499
  ? ["Review get_task_summary and audit_task before accepting the work."]
374
500
  : ["Resolve the reported failure before accepting the work."],
375
501
  }, null, 2), "utf-8");
376
- 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
+ }
377
510
 
378
511
  const finishedAt = new Date().toISOString();
379
512
  updateStatus(taskDir, {
@@ -385,7 +518,13 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
385
518
  error: finalError,
386
519
  changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
387
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,
388
525
  out_of_scope_changes: outOfScopeChanges,
526
+ new_out_of_scope_changes: newOutOfScopeChanges,
527
+ preexisting_external_dirty_files: preexistingExternalDirty,
389
528
  verify_status: verifyJson.status,
390
529
  verify_commands: verifyCommands,
391
530
  diff_available: changes.diff_available,
@@ -670,6 +809,12 @@ function buildResultMarkdown(input: {
670
809
  verify: VerifyReport;
671
810
  changes: ChangeArtifacts;
672
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[];
673
818
  }): string {
674
819
  const changed = input.changes.changed_files.length
675
820
  ? input.changes.changed_files.map((file) => `- ${file.change}: ${file.path}`).join("\n")
@@ -677,6 +822,45 @@ function buildResultMarkdown(input: {
677
822
  const outOfScope = input.outOfScopeChanges.length
678
823
  ? input.outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`).join("\n")
679
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
+
680
864
  return [
681
865
  "# PatchWarden Task Result",
682
866
  "",
@@ -695,6 +879,12 @@ function buildResultMarkdown(input: {
695
879
  "## Files changed",
696
880
  changed,
697
881
  "",
882
+ "## Changed file groups",
883
+ groupLines,
884
+ "",
885
+ "## Release artifacts",
886
+ artifactLines,
887
+ "",
698
888
  "## Verification",
699
889
  `- diff_available: ${input.changes.diff_available}`,
700
890
  `- diff_truncated: ${input.changes.diff_truncated}`,
@@ -703,10 +893,13 @@ function buildResultMarkdown(input: {
703
893
  `- verify_status: ${input.verify.status}`,
704
894
  `- verify_commands: ${input.verify.commands.length}/${input.verify.requested_commands.length} executed`,
705
895
  `- out_of_scope_changes: ${input.outOfScopeChanges.length}`,
896
+ `- artifact_status: ${input.artifactStatus || "collected"}`,
706
897
  "",
707
898
  "## Out-of-scope changes",
708
899
  outOfScope,
709
900
  "",
901
+ preexistingSection,
902
+ androidSection,
710
903
  "## Summary",
711
904
  input.error || "Agent execution and configured verification completed successfully.",
712
905
  "",
@@ -824,8 +1017,13 @@ function normalizeTimeout(value: unknown, config: ReturnType<typeof getConfig>):
824
1017
  return timeout;
825
1018
  }
826
1019
 
827
- function failBeforeExecution(taskId: string, taskDir: string, message: string): TaskRunResult {
828
- 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");
829
1027
  const current = readStatus(join(taskDir, "status.json"));
830
1028
  const now = new Date().toISOString();
831
1029
  const verify: VerifyReport = {
package/src/smoke-test.ts CHANGED
@@ -592,7 +592,7 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
592
592
  try {
593
593
  process.env.PATCHWARDEN_TOOL_PROFILE = "full";
594
594
  const fullTools = getToolDefs();
595
- if (fullTools.length !== 28) throw new Error(`Expected 28 full tools, got ${fullTools.length}`);
595
+ if (fullTools.length !== 30) throw new Error(`Expected 30 full tools, got ${fullTools.length}`);
596
596
 
597
597
  const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
598
598
  const names = coreTools.map((tool) => tool.name);
@@ -1926,10 +1926,10 @@ reloadConfig();
1926
1926
 
1927
1927
  let directSessionId = "";
1928
1928
 
1929
- test("M1. chatgpt_core still has 16 tools", () => {
1929
+ test("M1. chatgpt_core still has 17 tools", () => {
1930
1930
  const tools = getToolDefs();
1931
1931
  const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
1932
- if (coreTools.length !== 16) throw new Error(`Expected 16, got ${coreTools.length}`);
1932
+ if (coreTools.length !== 17) throw new Error(`Expected 17, got ${coreTools.length}`);
1933
1933
  if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
1934
1934
  throw new Error("Tool names mismatch");
1935
1935
  }
@@ -1942,10 +1942,10 @@ test("M2. chatgpt_direct disabled exposes only health_check", () => {
1942
1942
  if (disabledTools[0].name !== "health_check") throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
1943
1943
  });
1944
1944
 
1945
- test("M3. chatgpt_direct enabled has 9 tools", () => {
1945
+ test("M3. chatgpt_direct enabled has 10 tools", () => {
1946
1946
  const tools = getToolDefs();
1947
1947
  const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
1948
- if (directTools.length !== 9) throw new Error(`Expected 9, got ${directTools.length}`);
1948
+ if (directTools.length !== 10) throw new Error(`Expected 10, got ${directTools.length}`);
1949
1949
  if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
1950
1950
  throw new Error("Tool names mismatch");
1951
1951
  }
@@ -0,0 +1,158 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import {
7
+ diagnoseAndroidBuild,
8
+ type AndroidBuildDiagnosticReport,
9
+ } from "../../tools/androidDoctor.js";
10
+
11
+ const VALID_STATUSES = ["ok", "warn", "fail", "skip"] as const;
12
+
13
+ /** Restore an env var to its original value (deleted when undefined). */
14
+ function restoreEnv(key: string, value: string | undefined) {
15
+ if (value === undefined) delete process.env[key];
16
+ else process.env[key] = value;
17
+ }
18
+
19
+ describe("diagnoseAndroidBuild", () => {
20
+ let tempDir: string;
21
+ let savedJavaHome: string | undefined;
22
+ let savedAndroidHome: string | undefined;
23
+ let savedAndroidSdkRoot: string | undefined;
24
+
25
+ beforeEach(() => {
26
+ tempDir = mkdtempSync(join(tmpdir(), "pw-android-doctor-"));
27
+ savedJavaHome = process.env.JAVA_HOME;
28
+ savedAndroidHome = process.env.ANDROID_HOME;
29
+ savedAndroidSdkRoot = process.env.ANDROID_SDK_ROOT;
30
+ });
31
+
32
+ afterEach(() => {
33
+ rmSync(tempDir, { recursive: true, force: true });
34
+ restoreEnv("JAVA_HOME", savedJavaHome);
35
+ restoreEnv("ANDROID_HOME", savedAndroidHome);
36
+ restoreEnv("ANDROID_SDK_ROOT", savedAndroidSdkRoot);
37
+ });
38
+
39
+ it("returns a skip result when no android_app directory exists", () => {
40
+ const result = diagnoseAndroidBuild(tempDir);
41
+ assert.equal(result.status, "skip");
42
+ assert.equal((result as { reason: string }).reason, "No android_app directory found");
43
+ });
44
+
45
+ it("returns a structured diagnostic report when android_app exists", () => {
46
+ const androidApp = join(tempDir, "android_app");
47
+ mkdirSync(join(androidApp, "app"), { recursive: true });
48
+ mkdirSync(join(androidApp, "gradle", "wrapper"), { recursive: true });
49
+ writeFileSync(join(androidApp, "gradlew"), "#!/bin/sh\necho gradle\n");
50
+ writeFileSync(join(androidApp, "settings.gradle.kts"), 'pluginManagement { repositories { google() } }\n');
51
+ writeFileSync(join(androidApp, "app", "build.gradle.kts"), 'plugins { id("com.android.application") }\n');
52
+ writeFileSync(
53
+ join(androidApp, "gradle", "wrapper", "gradle-wrapper.properties"),
54
+ "distributionUrl=https://services.gradle.org/distributions/gradle-8.5-bin.zip\n",
55
+ );
56
+
57
+ // Make SDK-related checks deterministic regardless of the host environment.
58
+ delete process.env.ANDROID_HOME;
59
+ delete process.env.ANDROID_SDK_ROOT;
60
+
61
+ const result = diagnoseAndroidBuild(tempDir);
62
+ assert.notEqual(result.status, "skip");
63
+ const report = result as AndroidBuildDiagnosticReport;
64
+
65
+ assert.equal(report.repo_path, tempDir);
66
+ assert.equal(report.android_app_path, join(tempDir, "android_app"));
67
+ assert.ok(Array.isArray(report.checks));
68
+ assert.equal(report.checks.length, 10);
69
+
70
+ for (const item of report.checks) {
71
+ assert.equal(typeof item.check, "string");
72
+ assert.ok(item.check.length > 0);
73
+ assert.ok((VALID_STATUSES as readonly string[]).includes(item.status));
74
+ assert.equal(typeof item.reason, "string");
75
+ assert.equal(typeof item.suggested_fix, "string");
76
+ }
77
+
78
+ // Overall status follows the fail > warn > ok precedence.
79
+ const hasFail = report.checks.some((c) => c.status === "fail");
80
+ const hasWarn = report.checks.some((c) => c.status === "warn");
81
+ const expected = hasFail ? "fail" : hasWarn ? "warn" : "ok";
82
+ assert.equal(report.status, expected);
83
+
84
+ // The expected check names are all present.
85
+ const checkNames = report.checks.map((c) => c.check);
86
+ assert.ok(checkNames.includes("java -version"));
87
+ assert.ok(checkNames.includes("JAVA_HOME"));
88
+ assert.ok(checkNames.includes("ANDROID_HOME"));
89
+ assert.ok(checkNames.includes("ANDROID_SDK_ROOT"));
90
+ assert.ok(checkNames.includes("Android SDK platform"));
91
+ assert.ok(checkNames.includes("Android build-tools"));
92
+ assert.ok(checkNames.includes("Gradle wrapper"));
93
+ assert.ok(checkNames.includes("android_app/settings.gradle.kts"));
94
+ assert.ok(checkNames.includes("android_app/app/build.gradle.kts"));
95
+ assert.ok(checkNames.includes("APK output path"));
96
+
97
+ // Files we created should be detected as ok.
98
+ const gradleCheck = report.checks.find((c) => c.check === "Gradle wrapper");
99
+ assert.equal(gradleCheck?.status, "ok");
100
+
101
+ const settingsCheck = report.checks.find((c) => c.check === "android_app/settings.gradle.kts");
102
+ assert.equal(settingsCheck?.status, "ok");
103
+
104
+ const appBuildCheck = report.checks.find((c) => c.check === "android_app/app/build.gradle.kts");
105
+ assert.equal(appBuildCheck?.status, "ok");
106
+ });
107
+
108
+ it("reports a missing JAVA_HOME as warn or fail", () => {
109
+ const androidApp = join(tempDir, "android_app");
110
+ mkdirSync(androidApp, { recursive: true });
111
+
112
+ delete process.env.JAVA_HOME;
113
+ delete process.env.ANDROID_HOME;
114
+ delete process.env.ANDROID_SDK_ROOT;
115
+
116
+ const result = diagnoseAndroidBuild(tempDir);
117
+ const report = result as AndroidBuildDiagnosticReport;
118
+ const javaHomeCheck = report.checks.find((c) => c.check === "JAVA_HOME");
119
+ assert.ok(javaHomeCheck, "JAVA_HOME check should exist");
120
+ assert.ok(
121
+ javaHomeCheck!.status === "warn" || javaHomeCheck!.status === "fail",
122
+ `expected warn or fail, got ${javaHomeCheck!.status}`,
123
+ );
124
+ });
125
+
126
+ it("reports a missing ANDROID_HOME as fail with the SDK-missing message", () => {
127
+ const androidApp = join(tempDir, "android_app");
128
+ mkdirSync(androidApp, { recursive: true });
129
+
130
+ delete process.env.ANDROID_HOME;
131
+ delete process.env.ANDROID_SDK_ROOT;
132
+
133
+ const result = diagnoseAndroidBuild(tempDir);
134
+ const report = result as AndroidBuildDiagnosticReport;
135
+ const androidHomeCheck = report.checks.find((c) => c.check === "ANDROID_HOME");
136
+ assert.ok(androidHomeCheck, "ANDROID_HOME check should exist");
137
+ assert.equal(androidHomeCheck!.status, "fail");
138
+ assert.ok(
139
+ androidHomeCheck!.reason.includes(
140
+ "Android project exists, APK not built because Android SDK is missing.",
141
+ ),
142
+ `reason should mention the SDK-missing sentence, got: ${androidHomeCheck!.reason}`,
143
+ );
144
+
145
+ // With the SDK missing, the overall status must be fail.
146
+ assert.equal(report.status, "fail");
147
+
148
+ // The APK output check should also carry the SDK-missing reason and be skipped.
149
+ const apkCheck = report.checks.find((c) => c.check === "APK output path");
150
+ assert.ok(apkCheck, "APK output path check should exist");
151
+ assert.equal(apkCheck!.status, "skip");
152
+ assert.ok(
153
+ apkCheck!.reason.includes(
154
+ "Android project exists, APK not built because Android SDK is missing.",
155
+ ),
156
+ );
157
+ });
158
+ });