patchwarden 0.6.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 (81) hide show
  1. package/README.md +3 -3
  2. package/dist/doctor.js +1 -1
  3. package/dist/logging.d.ts +52 -0
  4. package/dist/logging.js +123 -0
  5. package/dist/runner/changeCapture.d.ts +41 -0
  6. package/dist/runner/changeCapture.js +171 -1
  7. package/dist/runner/runTask.js +204 -24
  8. package/dist/smoke-test.js +8 -8
  9. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  10. package/dist/test/unit/android-doctor.test.js +118 -0
  11. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  12. package/dist/test/unit/chinese-path.test.js +91 -0
  13. package/dist/test/unit/command-guard.test.d.ts +1 -0
  14. package/dist/test/unit/command-guard.test.js +160 -0
  15. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  16. package/dist/test/unit/direct-guards.test.js +213 -0
  17. package/dist/test/unit/logging.test.d.ts +1 -0
  18. package/dist/test/unit/logging.test.js +275 -0
  19. package/dist/test/unit/path-guard.test.d.ts +1 -0
  20. package/dist/test/unit/path-guard.test.js +109 -0
  21. package/dist/test/unit/safe-status.test.d.ts +1 -0
  22. package/dist/test/unit/safe-status.test.js +165 -0
  23. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  24. package/dist/test/unit/sensitive-guard.test.js +104 -0
  25. package/dist/test/unit/sync-file.test.d.ts +1 -0
  26. package/dist/test/unit/sync-file.test.js +154 -0
  27. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  28. package/dist/test/unit/watcher-status.test.js +169 -0
  29. package/dist/tools/androidDoctor.d.ts +38 -0
  30. package/dist/tools/androidDoctor.js +391 -0
  31. package/dist/tools/auditTask.js +11 -5
  32. package/dist/tools/getTaskSummary.d.ts +3 -0
  33. package/dist/tools/getTaskSummary.js +15 -1
  34. package/dist/tools/healthCheck.d.ts +5 -0
  35. package/dist/tools/healthCheck.js +21 -0
  36. package/dist/tools/registry.js +53 -0
  37. package/dist/tools/safeStatus.d.ts +19 -0
  38. package/dist/tools/safeStatus.js +72 -0
  39. package/dist/tools/syncFile.d.ts +18 -0
  40. package/dist/tools/syncFile.js +65 -0
  41. package/dist/tools/taskOutputs.d.ts +2 -2
  42. package/dist/tools/toolCatalog.d.ts +2 -2
  43. package/dist/tools/toolCatalog.js +2 -0
  44. package/dist/version.d.ts +2 -2
  45. package/dist/version.js +2 -2
  46. package/dist/watcherStatus.d.ts +1 -0
  47. package/dist/watcherStatus.js +96 -4
  48. package/docs/performance-notes.md +55 -0
  49. package/docs/release-v0.6.1.md +75 -0
  50. package/package.json +3 -2
  51. package/scripts/http-mcp-smoke.js +10 -2
  52. package/scripts/lifecycle-smoke.js +336 -2
  53. package/scripts/mcp-manifest-check.js +30 -7
  54. package/scripts/mcp-smoke.js +11 -8
  55. package/scripts/pack-clean.js +157 -1
  56. package/scripts/unit-tests.js +36 -0
  57. package/src/doctor.ts +1 -1
  58. package/src/logging.ts +152 -0
  59. package/src/runner/changeCapture.ts +212 -1
  60. package/src/runner/runTask.ts +220 -22
  61. package/src/smoke-test.ts +5 -5
  62. package/src/test/unit/android-doctor.test.ts +158 -0
  63. package/src/test/unit/chinese-path.test.ts +106 -0
  64. package/src/test/unit/command-guard.test.ts +221 -0
  65. package/src/test/unit/direct-guards.test.ts +297 -0
  66. package/src/test/unit/logging.test.ts +325 -0
  67. package/src/test/unit/path-guard.test.ts +150 -0
  68. package/src/test/unit/safe-status.test.ts +187 -0
  69. package/src/test/unit/sensitive-guard.test.ts +124 -0
  70. package/src/test/unit/sync-file.test.ts +231 -0
  71. package/src/test/unit/watcher-status.test.ts +190 -0
  72. package/src/tools/androidDoctor.ts +424 -0
  73. package/src/tools/auditTask.ts +11 -5
  74. package/src/tools/getTaskSummary.ts +22 -1
  75. package/src/tools/healthCheck.ts +22 -0
  76. package/src/tools/registry.ts +63 -0
  77. package/src/tools/safeStatus.ts +96 -0
  78. package/src/tools/syncFile.ts +122 -0
  79. package/src/tools/toolCatalog.ts +2 -0
  80. package/src/version.ts +2 -2
  81. package/src/watcherStatus.ts +101 -4
@@ -8,10 +8,13 @@ import { writeTaskProgress } from "../taskProgress.js";
8
8
  import { writeTaskRuntime } from "../taskRuntime.js";
9
9
  import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
10
10
  import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
11
- import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, emptyArtifactHygiene, writeSnapshot, } from "./changeCapture.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";
12
14
  const HEARTBEAT_INTERVAL_MS = 2000;
13
15
  const GRACEFUL_KILL_MS = 2000;
14
16
  const MAX_CAPTURE_CHARS = 100_000;
17
+ const ARTIFACT_COLLECTION_TIMEOUT_MS = 60_000;
15
18
  export async function runTask(taskId) {
16
19
  const config = getConfig();
17
20
  const tasksDir = getTasksDir(config);
@@ -34,7 +37,7 @@ export async function runTask(taskId) {
34
37
  timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
35
38
  }
36
39
  catch (error) {
37
- return failBeforeExecution(taskId, taskDir, errorMessage(error));
40
+ return failBeforeExecution(taskId, taskDir, errorMessage(error), error);
38
41
  }
39
42
  const startedAtMs = Date.now();
40
43
  const deadlineMs = startedAtMs + timeoutSeconds * 1000;
@@ -44,14 +47,14 @@ export async function runTask(taskId) {
44
47
  }
45
48
  catch (error) {
46
49
  const message = `repo_path validation failed: ${errorMessage(error)}`;
47
- return failBeforeExecution(taskId, taskDir, message);
50
+ return failBeforeExecution(taskId, taskDir, message, error);
48
51
  }
49
52
  try {
50
53
  verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config, repoPath);
51
54
  }
52
55
  catch (error) {
53
56
  const message = `verification metadata validation failed: ${errorMessage(error)}`;
54
- return failBeforeExecution(taskId, taskDir, message);
57
+ return failBeforeExecution(taskId, taskDir, message, error);
55
58
  }
56
59
  // ── Assessment freshness revalidation before execution ──
57
60
  const assessmentId = String(initialStatus.assessment_id || "");
@@ -66,7 +69,7 @@ export async function runTask(taskId) {
66
69
  }
67
70
  catch (error) {
68
71
  const message = `assessment validation error: ${errorMessage(error)}`;
69
- return failBeforeExecution(taskId, taskDir, message);
72
+ return failBeforeExecution(taskId, taskDir, message, error);
70
73
  }
71
74
  }
72
75
  updateStatus(taskDir, {
@@ -81,21 +84,25 @@ export async function runTask(taskId) {
81
84
  setTaskPhase(taskDir, "preparing", null, "Capturing pre-task repository state.");
82
85
  let beforeSnapshot;
83
86
  let beforeWorkspaceSnapshot;
87
+ let externalDirtyBaseline = [];
84
88
  try {
85
89
  beforeSnapshot = captureRepoSnapshot(repoPath);
86
90
  beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : captureRepoSnapshot(wsRoot);
87
91
  writeSnapshot(taskDir, "git-before.json", beforeSnapshot);
88
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);
89
95
  }
90
96
  catch (error) {
91
97
  const message = `Pre-task snapshot failed: ${errorMessage(error)}`;
92
- return failBeforeExecution(taskId, taskDir, message);
98
+ return failBeforeExecution(taskId, taskDir, message, error);
93
99
  }
94
100
  let agentResult = null;
95
101
  let testResult = skippedTest(testCommand, repoPath, "Agent did not complete successfully.");
96
102
  const verifyResults = [];
97
103
  let finalStatus = "failed";
98
104
  let finalError = null;
105
+ let lastCaughtError = null;
99
106
  try {
100
107
  const planFile = resolve(plansDir, planId, "plan.md");
101
108
  if (!existsSync(planFile))
@@ -171,19 +178,45 @@ export async function runTask(taskId) {
171
178
  }
172
179
  }
173
180
  catch (error) {
181
+ lastCaughtError = error;
174
182
  finalError = errorMessage(error);
175
183
  }
176
184
  setTaskPhase(taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
185
+ const artifactCollectionStartedAt = new Date().toISOString();
177
186
  let changes;
187
+ let artifactStatus = "collected";
188
+ let artifactCollectionError = null;
189
+ let timeoutHandle;
178
190
  try {
179
- const afterSnapshot = captureRepoSnapshot(repoPath);
180
- writeSnapshot(taskDir, "git-after.json", afterSnapshot);
181
- 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;
182
205
  }
183
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
+ }
184
217
  changes = {
185
218
  changed_files: [],
186
- diff: `(change capture failed: ${errorMessage(error)})\n`,
219
+ diff: `(change capture failed: ${artifactCollectionError})\n`,
187
220
  diff_available: false,
188
221
  diff_truncated: false,
189
222
  diff_size_bytes: 0,
@@ -193,16 +226,50 @@ export async function runTask(taskId) {
193
226
  workspace_dirty_before: beforeSnapshot.workspace_dirty,
194
227
  workspace_dirty_after: beforeSnapshot.workspace_dirty,
195
228
  patch_mode: "hash_only",
196
- unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
229
+ unavailable_reason: `Change capture failed: ${artifactCollectionError}`,
197
230
  artifact_hygiene: emptyArtifactHygiene(),
198
231
  };
199
- finalError ||= `Change capture failed: ${errorMessage(error)}`;
200
- 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
+ }
201
262
  }
263
+ const artifactCollectionFinishedAt = new Date().toISOString();
202
264
  let outOfScopeChanges = [];
265
+ let preexistingExternalDirty = externalDirtyBaseline;
266
+ let newOutOfScopeChanges = [];
203
267
  try {
204
268
  const afterWorkspaceSnapshot = repoPath === wsRoot ? captureRepoSnapshot(repoPath) : captureRepoSnapshot(wsRoot);
205
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);
206
273
  outOfScopeChanges = compareSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
207
274
  .filter((file) => !isPathInside(resolve(wsRoot, file.path), repoPath) ||
208
275
  Boolean(file.old_path && !isPathInside(resolve(wsRoot, file.old_path), repoPath)));
@@ -212,15 +279,20 @@ export async function runTask(taskId) {
212
279
  if (finalStatus === "done")
213
280
  finalStatus = "failed";
214
281
  }
215
- 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) {
216
287
  finalStatus = "failed_scope_violation";
217
- 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.`;
218
289
  writeFileSync(join(taskDir, "rollback-plan.json"), JSON.stringify({
219
290
  task_id: taskId,
220
291
  status: "review_required",
221
292
  automatic_rollback_performed: false,
222
293
  warning: "Review ownership and concurrent edits before rollback. PatchWarden did not modify or restore these files.",
223
- out_of_scope_changes: outOfScopeChanges,
294
+ out_of_scope_changes: newOutOfScopeChanges,
295
+ preexisting_external_dirty_files: preexistingExternalDirty,
224
296
  }, null, 2), "utf-8");
225
297
  writeFileSync(join(taskDir, "rollback_scope_violation_plan.md"), [
226
298
  "# Scope Violation Rollback Plan",
@@ -229,8 +301,8 @@ export async function runTask(taskId) {
229
301
  "",
230
302
  "PatchWarden did not automatically roll back any file. Review concurrent or user-owned edits before acting.",
231
303
  "",
232
- "## Out-of-scope files only",
233
- ...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}`),
234
306
  "",
235
307
  ].join("\n"), "utf-8");
236
308
  }
@@ -241,6 +313,10 @@ export async function runTask(taskId) {
241
313
  writeFileSync(join(taskDir, "git.diff"), changes.diff, "utf-8");
242
314
  writeFileSync(join(taskDir, "diff.patch"), changes.diff, "utf-8");
243
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);
244
320
  writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
245
321
  task_id: taskId,
246
322
  additions: changes.additions,
@@ -249,7 +325,10 @@ export async function runTask(taskId) {
249
325
  }, null, 2), "utf-8");
250
326
  writeFileSync(join(taskDir, "test.log"), buildTestLog(testResult), "utf-8");
251
327
  const verifyJson = buildVerifyJson(verifyCommands, verifyResults, repoPath);
252
- 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) {
253
332
  verifyJson.status = "failed";
254
333
  verifyJson.failure_reason = "scope_violation";
255
334
  }
@@ -263,6 +342,20 @@ export async function runTask(taskId) {
263
342
  finalStatus = "failed";
264
343
  const finalPhase = finalStatus === "done" ? "completed" : finalStatus;
265
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
+ }
266
359
  const resultMd = buildResultMarkdown({
267
360
  taskId,
268
361
  planId,
@@ -274,6 +367,12 @@ export async function runTask(taskId) {
274
367
  verify: verifyJson,
275
368
  changes,
276
369
  outOfScopeChanges,
370
+ artifactStatus,
371
+ artifactManifest,
372
+ changedFileGroups,
373
+ androidDiagnostic,
374
+ androidWarning,
375
+ preexistingWarnings,
277
376
  });
278
377
  writeFileSync(join(taskDir, "result.md"), resultMd, "utf-8");
279
378
  writeFileSync(join(taskDir, "result.json"), JSON.stringify({
@@ -288,8 +387,26 @@ export async function runTask(taskId) {
288
387
  change_policy: changePolicy,
289
388
  summary: finalError || "Agent execution and configured verification completed successfully.",
290
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
+ },
291
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,
292
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,
293
410
  verify_status: verifyJson.status,
294
411
  verify_commands: verifyJson.commands,
295
412
  commands_run: verifyJson.commands.map((command) => ({
@@ -300,12 +417,15 @@ export async function runTask(taskId) {
300
417
  commands_observed: [],
301
418
  verify: verifyJson,
302
419
  artifacts: [
303
- "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",
304
421
  ...(outOfScopeChanges.length > 0 ? ["rollback_scope_violation_plan.md", "rollback-plan.json"] : []),
422
+ ...(artifactStatus !== "collected" ? ["partial_result.md"] : []),
305
423
  ],
306
424
  warnings: [
307
425
  ...beforeSnapshot.warnings,
308
426
  ...(changes.diff_truncated ? ["diff.patch was truncated; changed-files.json retains file evidence."] : []),
427
+ ...preexistingWarnings,
428
+ ...(androidWarning ? [androidWarning] : []),
309
429
  ],
310
430
  errors: finalError ? [finalError] : [],
311
431
  known_issues: finalError ? [finalError] : [],
@@ -317,8 +437,14 @@ export async function runTask(taskId) {
317
437
  ? ["Review get_task_summary and audit_task before accepting the work."]
318
438
  : ["Resolve the reported failure before accepting the work."],
319
439
  }, null, 2), "utf-8");
320
- if (finalError)
321
- 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
+ }
322
448
  const finishedAt = new Date().toISOString();
323
449
  updateStatus(taskDir, {
324
450
  status: finalStatus,
@@ -329,7 +455,13 @@ export async function runTask(taskId) {
329
455
  error: finalError,
330
456
  changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
331
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,
332
462
  out_of_scope_changes: outOfScopeChanges,
463
+ new_out_of_scope_changes: newOutOfScopeChanges,
464
+ preexisting_external_dirty_files: preexistingExternalDirty,
333
465
  verify_status: verifyJson.status,
334
466
  verify_commands: verifyCommands,
335
467
  diff_available: changes.diff_available,
@@ -586,6 +718,40 @@ function buildResultMarkdown(input) {
586
718
  const outOfScope = input.outOfScopeChanges.length
587
719
  ? input.outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`).join("\n")
588
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
+ : "";
589
755
  return [
590
756
  "# PatchWarden Task Result",
591
757
  "",
@@ -604,6 +770,12 @@ function buildResultMarkdown(input) {
604
770
  "## Files changed",
605
771
  changed,
606
772
  "",
773
+ "## Changed file groups",
774
+ groupLines,
775
+ "",
776
+ "## Release artifacts",
777
+ artifactLines,
778
+ "",
607
779
  "## Verification",
608
780
  `- diff_available: ${input.changes.diff_available}`,
609
781
  `- diff_truncated: ${input.changes.diff_truncated}`,
@@ -612,10 +784,13 @@ function buildResultMarkdown(input) {
612
784
  `- verify_status: ${input.verify.status}`,
613
785
  `- verify_commands: ${input.verify.commands.length}/${input.verify.requested_commands.length} executed`,
614
786
  `- out_of_scope_changes: ${input.outOfScopeChanges.length}`,
787
+ `- artifact_status: ${input.artifactStatus || "collected"}`,
615
788
  "",
616
789
  "## Out-of-scope changes",
617
790
  outOfScope,
618
791
  "",
792
+ preexistingSection,
793
+ androidSection,
619
794
  "## Summary",
620
795
  input.error || "Agent execution and configured verification completed successfully.",
621
796
  "",
@@ -721,8 +896,13 @@ function normalizeTimeout(value, config) {
721
896
  }
722
897
  return timeout;
723
898
  }
724
- function failBeforeExecution(taskId, taskDir, message) {
725
- 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");
726
906
  const current = readStatus(join(taskDir, "status.json"));
727
907
  const now = new Date().toISOString();
728
908
  const verify = {
@@ -533,8 +533,8 @@ test("D8b. tool profiles are exact and schema changes alter the manifest hash",
533
533
  try {
534
534
  process.env.PATCHWARDEN_TOOL_PROFILE = "full";
535
535
  const fullTools = getToolDefs();
536
- if (fullTools.length !== 28)
537
- throw new Error(`Expected 28 full tools, got ${fullTools.length}`);
536
+ if (fullTools.length !== 30)
537
+ throw new Error(`Expected 30 full tools, got ${fullTools.length}`);
538
538
  const coreTools = selectToolsForProfile(fullTools, "chatgpt_core", getConfig().enableDirectProfile);
539
539
  const names = coreTools.map((tool) => tool.name);
540
540
  if (JSON.stringify(names) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
@@ -1866,11 +1866,11 @@ writeFileSync(directConfigPath, JSON.stringify({
1866
1866
  process.env.PATCHWARDEN_CONFIG = directConfigPath;
1867
1867
  reloadConfig();
1868
1868
  let directSessionId = "";
1869
- test("M1. chatgpt_core still has 16 tools", () => {
1869
+ test("M1. chatgpt_core still has 17 tools", () => {
1870
1870
  const tools = getToolDefs();
1871
1871
  const coreTools = selectToolsForProfile(tools, "chatgpt_core", true);
1872
- if (coreTools.length !== 16)
1873
- throw new Error(`Expected 16, got ${coreTools.length}`);
1872
+ if (coreTools.length !== 17)
1873
+ throw new Error(`Expected 17, got ${coreTools.length}`);
1874
1874
  if (JSON.stringify(coreTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_CORE_TOOL_NAMES)) {
1875
1875
  throw new Error("Tool names mismatch");
1876
1876
  }
@@ -1883,11 +1883,11 @@ test("M2. chatgpt_direct disabled exposes only health_check", () => {
1883
1883
  if (disabledTools[0].name !== "health_check")
1884
1884
  throw new Error(`Expected health_check, got ${disabledTools[0].name}`);
1885
1885
  });
1886
- test("M3. chatgpt_direct enabled has 9 tools", () => {
1886
+ test("M3. chatgpt_direct enabled has 10 tools", () => {
1887
1887
  const tools = getToolDefs();
1888
1888
  const directTools = selectToolsForProfile(tools, "chatgpt_direct", true);
1889
- if (directTools.length !== 9)
1890
- throw new Error(`Expected 9, got ${directTools.length}`);
1889
+ if (directTools.length !== 10)
1890
+ throw new Error(`Expected 10, got ${directTools.length}`);
1891
1891
  if (JSON.stringify(directTools.map((t) => t.name)) !== JSON.stringify(CHATGPT_DIRECT_TOOL_NAMES)) {
1892
1892
  throw new Error("Tool names mismatch");
1893
1893
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,118 @@
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 { diagnoseAndroidBuild, } from "../../tools/androidDoctor.js";
7
+ const VALID_STATUSES = ["ok", "warn", "fail", "skip"];
8
+ /** Restore an env var to its original value (deleted when undefined). */
9
+ function restoreEnv(key, value) {
10
+ if (value === undefined)
11
+ delete process.env[key];
12
+ else
13
+ process.env[key] = value;
14
+ }
15
+ describe("diagnoseAndroidBuild", () => {
16
+ let tempDir;
17
+ let savedJavaHome;
18
+ let savedAndroidHome;
19
+ let savedAndroidSdkRoot;
20
+ beforeEach(() => {
21
+ tempDir = mkdtempSync(join(tmpdir(), "pw-android-doctor-"));
22
+ savedJavaHome = process.env.JAVA_HOME;
23
+ savedAndroidHome = process.env.ANDROID_HOME;
24
+ savedAndroidSdkRoot = process.env.ANDROID_SDK_ROOT;
25
+ });
26
+ afterEach(() => {
27
+ rmSync(tempDir, { recursive: true, force: true });
28
+ restoreEnv("JAVA_HOME", savedJavaHome);
29
+ restoreEnv("ANDROID_HOME", savedAndroidHome);
30
+ restoreEnv("ANDROID_SDK_ROOT", savedAndroidSdkRoot);
31
+ });
32
+ it("returns a skip result when no android_app directory exists", () => {
33
+ const result = diagnoseAndroidBuild(tempDir);
34
+ assert.equal(result.status, "skip");
35
+ assert.equal(result.reason, "No android_app directory found");
36
+ });
37
+ it("returns a structured diagnostic report when android_app exists", () => {
38
+ const androidApp = join(tempDir, "android_app");
39
+ mkdirSync(join(androidApp, "app"), { recursive: true });
40
+ mkdirSync(join(androidApp, "gradle", "wrapper"), { recursive: true });
41
+ writeFileSync(join(androidApp, "gradlew"), "#!/bin/sh\necho gradle\n");
42
+ writeFileSync(join(androidApp, "settings.gradle.kts"), 'pluginManagement { repositories { google() } }\n');
43
+ writeFileSync(join(androidApp, "app", "build.gradle.kts"), 'plugins { id("com.android.application") }\n');
44
+ writeFileSync(join(androidApp, "gradle", "wrapper", "gradle-wrapper.properties"), "distributionUrl=https://services.gradle.org/distributions/gradle-8.5-bin.zip\n");
45
+ // Make SDK-related checks deterministic regardless of the host environment.
46
+ delete process.env.ANDROID_HOME;
47
+ delete process.env.ANDROID_SDK_ROOT;
48
+ const result = diagnoseAndroidBuild(tempDir);
49
+ assert.notEqual(result.status, "skip");
50
+ const report = result;
51
+ assert.equal(report.repo_path, tempDir);
52
+ assert.equal(report.android_app_path, join(tempDir, "android_app"));
53
+ assert.ok(Array.isArray(report.checks));
54
+ assert.equal(report.checks.length, 10);
55
+ for (const item of report.checks) {
56
+ assert.equal(typeof item.check, "string");
57
+ assert.ok(item.check.length > 0);
58
+ assert.ok(VALID_STATUSES.includes(item.status));
59
+ assert.equal(typeof item.reason, "string");
60
+ assert.equal(typeof item.suggested_fix, "string");
61
+ }
62
+ // Overall status follows the fail > warn > ok precedence.
63
+ const hasFail = report.checks.some((c) => c.status === "fail");
64
+ const hasWarn = report.checks.some((c) => c.status === "warn");
65
+ const expected = hasFail ? "fail" : hasWarn ? "warn" : "ok";
66
+ assert.equal(report.status, expected);
67
+ // The expected check names are all present.
68
+ const checkNames = report.checks.map((c) => c.check);
69
+ assert.ok(checkNames.includes("java -version"));
70
+ assert.ok(checkNames.includes("JAVA_HOME"));
71
+ assert.ok(checkNames.includes("ANDROID_HOME"));
72
+ assert.ok(checkNames.includes("ANDROID_SDK_ROOT"));
73
+ assert.ok(checkNames.includes("Android SDK platform"));
74
+ assert.ok(checkNames.includes("Android build-tools"));
75
+ assert.ok(checkNames.includes("Gradle wrapper"));
76
+ assert.ok(checkNames.includes("android_app/settings.gradle.kts"));
77
+ assert.ok(checkNames.includes("android_app/app/build.gradle.kts"));
78
+ assert.ok(checkNames.includes("APK output path"));
79
+ // Files we created should be detected as ok.
80
+ const gradleCheck = report.checks.find((c) => c.check === "Gradle wrapper");
81
+ assert.equal(gradleCheck?.status, "ok");
82
+ const settingsCheck = report.checks.find((c) => c.check === "android_app/settings.gradle.kts");
83
+ assert.equal(settingsCheck?.status, "ok");
84
+ const appBuildCheck = report.checks.find((c) => c.check === "android_app/app/build.gradle.kts");
85
+ assert.equal(appBuildCheck?.status, "ok");
86
+ });
87
+ it("reports a missing JAVA_HOME as warn or fail", () => {
88
+ const androidApp = join(tempDir, "android_app");
89
+ mkdirSync(androidApp, { recursive: true });
90
+ delete process.env.JAVA_HOME;
91
+ delete process.env.ANDROID_HOME;
92
+ delete process.env.ANDROID_SDK_ROOT;
93
+ const result = diagnoseAndroidBuild(tempDir);
94
+ const report = result;
95
+ const javaHomeCheck = report.checks.find((c) => c.check === "JAVA_HOME");
96
+ assert.ok(javaHomeCheck, "JAVA_HOME check should exist");
97
+ assert.ok(javaHomeCheck.status === "warn" || javaHomeCheck.status === "fail", `expected warn or fail, got ${javaHomeCheck.status}`);
98
+ });
99
+ it("reports a missing ANDROID_HOME as fail with the SDK-missing message", () => {
100
+ const androidApp = join(tempDir, "android_app");
101
+ mkdirSync(androidApp, { recursive: true });
102
+ delete process.env.ANDROID_HOME;
103
+ delete process.env.ANDROID_SDK_ROOT;
104
+ const result = diagnoseAndroidBuild(tempDir);
105
+ const report = result;
106
+ const androidHomeCheck = report.checks.find((c) => c.check === "ANDROID_HOME");
107
+ assert.ok(androidHomeCheck, "ANDROID_HOME check should exist");
108
+ assert.equal(androidHomeCheck.status, "fail");
109
+ assert.ok(androidHomeCheck.reason.includes("Android project exists, APK not built because Android SDK is missing."), `reason should mention the SDK-missing sentence, got: ${androidHomeCheck.reason}`);
110
+ // With the SDK missing, the overall status must be fail.
111
+ assert.equal(report.status, "fail");
112
+ // The APK output check should also carry the SDK-missing reason and be skipped.
113
+ const apkCheck = report.checks.find((c) => c.check === "APK output path");
114
+ assert.ok(apkCheck, "APK output path check should exist");
115
+ assert.equal(apkCheck.status, "skip");
116
+ assert.ok(apkCheck.reason.includes("Android project exists, APK not built because Android SDK is missing."));
117
+ });
118
+ });
@@ -0,0 +1 @@
1
+ export {};