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
@@ -39,8 +39,8 @@ async function test(name, fn) {
39
39
  }
40
40
  }
41
41
 
42
- function git(args) {
43
- const result = spawnSync("git", args, { cwd: repoPath, encoding: "utf-8" });
42
+ function git(args, cwd = repoPath) {
43
+ const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
44
44
  if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
45
45
  }
46
46
 
@@ -53,6 +53,18 @@ async function waitForRunning(getTaskStatus, taskId) {
53
53
  throw new Error(`Task ${taskId} did not enter running state`);
54
54
  }
55
55
 
56
+ async function raceWithTimeout(promise, ms, msg) {
57
+ let timer;
58
+ const timeout = new Promise((_, reject) => {
59
+ timer = setTimeout(() => reject(new Error(msg)), ms);
60
+ });
61
+ try {
62
+ return await Promise.race([promise, timeout]);
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+
56
68
  console.log("\n=== PatchWarden Lifecycle Smoke Tests ===\n");
57
69
 
58
70
  try {
@@ -70,10 +82,14 @@ try {
70
82
  writeFileSync(join(repoPath, "main.js"), "console.log('fixture');\n", "utf-8");
71
83
  writeFileSync(join(repoPath, "second.js"), "console.log('second');\n", "utf-8");
72
84
  writeFileSync(join(repoPath, "delete-me.txt"), "delete fixture\n", "utf-8");
85
+ writeFileSync(join(repoPath, ".gitignore"), "dist/\nrelease/\nsync-store.json\n*.log\n", "utf-8");
86
+ mkdirSync(join(repoPath, "release"), { recursive: true });
87
+ writeFileSync(join(repoPath, "release", "tracked.txt"), "tracked artifact fixture\n", "utf-8");
73
88
  mkdirSync(plainRepoPath, { recursive: true });
74
89
  writeFileSync(join(plainRepoPath, "README.md"), "# Non-Git fixture\n", "utf-8");
75
90
  git(["init"]);
76
- git(["add", "README.md", "main.js", "second.js", "delete-me.txt"]);
91
+ git(["add", "README.md", "main.js", "second.js", "delete-me.txt", ".gitignore"]);
92
+ git(["add", "-f", "release/tracked.txt"]);
77
93
  git(["-c", "user.name=PatchWarden Test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"]);
78
94
 
79
95
  writeFileSync(
@@ -114,6 +130,13 @@ try {
114
130
  command: process.execPath,
115
131
  args: ["-e", "require('fs').writeFileSync('fixture.bin',Buffer.from([0,1,2,3,255,0,10]))"],
116
132
  },
133
+ artifactwriter: {
134
+ command: process.execPath,
135
+ args: [
136
+ "-e",
137
+ "const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.writeFileSync('dist/app.exe','build');fs.writeFileSync('sync-store.json','{}');fs.appendFileSync('release/tracked.txt','changed\\n');fs.writeFileSync('generated.exe','review')",
138
+ ],
139
+ },
117
140
  },
118
141
  allowedTestCommands: ["node --check main.js", "node --check second.js", "node --check missing.js"],
119
142
  maxReadFileBytes: 200000,
@@ -133,12 +156,14 @@ try {
133
156
  const { getDiff } = await import("../dist/tools/taskOutputs.js");
134
157
  const { listAgents } = await import("../dist/tools/listAgents.js");
135
158
  const { getTaskSummary } = await import("../dist/tools/getTaskSummary.js");
159
+ const { auditTask } = await import("../dist/tools/auditTask.js");
136
160
  const { waitForTask } = await import("../dist/tools/waitForTask.js");
137
161
  const { runTask } = await import("../dist/runner/runTask.js");
162
+ const { reloadConfig } = await import("../dist/config.js");
138
163
 
139
164
  await test("list_agents reports configured executables", async () => {
140
165
  const result = listAgents();
141
- if (result.total !== 7 || result.agents.some((agent) => !agent.available)) {
166
+ if (result.total !== 8 || result.agents.some((agent) => !agent.available)) {
142
167
  throw new Error(`Unexpected agent availability: ${JSON.stringify(result)}`);
143
168
  }
144
169
  });
@@ -295,6 +320,35 @@ try {
295
320
  }
296
321
  });
297
322
 
323
+ await test("artifact hygiene separates source, ignored output, runtime data, and suspicious changes", async () => {
324
+ const plan = savePlan({ title: "Artifact hygiene", content: "Generate representative task outputs." });
325
+ const task = createTask({ plan_id: plan.plan_id, agent: "artifactwriter", repo_path: "repo" });
326
+ const result = await runTask(task.task_id);
327
+ if (result.status !== "done") throw new Error(`Artifact task failed: ${JSON.stringify(result)}`);
328
+ const standard = getTaskSummary(task.task_id);
329
+ const compact = getTaskSummary(task.task_id, { view: "compact", max_items: 1 });
330
+ const counts = standard.artifact_hygiene?.counts || {};
331
+ if (
332
+ counts.tracked_build_artifacts < 1 ||
333
+ counts.ignored_untracked_artifacts < 2 ||
334
+ counts.runtime_generated_files < 1 ||
335
+ counts.suspicious_changes < 2
336
+ ) {
337
+ throw new Error(`Artifact classification mismatch: ${JSON.stringify(standard.artifact_hygiene)}`);
338
+ }
339
+ if (compact.view !== "compact" || "log_tails" in compact || compact.artifact_hygiene.max_items !== 1) {
340
+ throw new Error(`Compact summary leaked standard detail: ${JSON.stringify(compact)}`);
341
+ }
342
+ if (!Array.isArray(standard.changed_files) || standard.changed_files.length < 4) {
343
+ throw new Error("Standard summary no longer preserves full changed-file evidence");
344
+ }
345
+ const audit = auditTask(task.task_id);
346
+ const hygieneCheck = audit.checks.find((check) => check.name === "artifact_hygiene");
347
+ if (!hygieneCheck || hygieneCheck.result !== "warn") {
348
+ throw new Error(`Audit did not surface suspicious artifact evidence: ${JSON.stringify(audit)}`);
349
+ }
350
+ });
351
+
298
352
  await test("deleted tracked files are identified with file stats", async () => {
299
353
  const plan = savePlan({ title: "Delete fixture", content: "Delete the designated fixture file." });
300
354
  const task = createTask({ plan_id: plan.plan_id, agent: "deleter", repo_path: "repo" });
@@ -311,10 +365,13 @@ try {
311
365
  const task = createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "repo" });
312
366
  const running = runTask(task.task_id);
313
367
  const waited = await waitForTask(task.task_id, 5);
314
- await running;
368
+ await raceWithTimeout(running, 15000, "wait_for_task loop did not terminate within 15s");
315
369
  if (!waited.terminal || waited.continuation_required || !waited.summary) {
316
370
  throw new Error(`Unexpected wait response: ${JSON.stringify(waited)}`);
317
371
  }
372
+ if (waited.summary.view !== "compact" || "log_tails" in waited.summary) {
373
+ throw new Error(`Terminal wait should embed compact evidence: ${JSON.stringify(waited.summary)}`);
374
+ }
318
375
  });
319
376
 
320
377
  await test("wait_for_task explicitly requires another call when the task is not terminal", async () => {
@@ -338,7 +395,7 @@ try {
338
395
  throw new Error(`Running summary incomplete: ${JSON.stringify(summary)}`);
339
396
  }
340
397
  cancelTask(task.task_id);
341
- await running;
398
+ await raceWithTimeout(running, 15000, "running summary cancel did not terminate within 15s");
342
399
  });
343
400
 
344
401
  await test("verification failure produces failed_verification and structured evidence", async () => {
@@ -398,6 +455,339 @@ try {
398
455
  }
399
456
  });
400
457
 
458
+ // ═══════════════════════════════════════════════════════════
459
+ // Phase 4: Tracked external dirty tests (separate workspace)
460
+ // ═══════════════════════════════════════════════════════════
461
+
462
+ await test("pre-existing tracked external dirty unchanged → warning only, done", async () => {
463
+ // Create a separate workspace with git at the root
464
+ const ws2 = join(tempRoot, "workspace-tracked-dirty");
465
+ const repo2 = join(ws2, "repo");
466
+ const ext2 = join(ws2, "external");
467
+ mkdirSync(repo2, { recursive: true });
468
+ mkdirSync(ext2, { recursive: true });
469
+ mkdirSync(join(ws2, ".patchwarden"), { recursive: true });
470
+ writeFileSync(join(ws2, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
471
+ status: "running", pid: process.pid, instance_id: "td-watcher", launcher_pid: process.pid,
472
+ started_at: new Date().toISOString(), last_heartbeat_at: new Date().toISOString(),
473
+ }), "utf-8");
474
+
475
+ writeFileSync(join(repo2, "main.js"), "console.log('main');\n", "utf-8");
476
+ writeFileSync(join(ext2, "external-tracked.txt"), "external tracked content\n", "utf-8");
477
+
478
+ git(["init"], ws2);
479
+ git(["add", "repo/main.js", "external/external-tracked.txt"], ws2);
480
+ git(["-c", "user.name=T", "-c", "user.email=t@e", "commit", "-m", "init"], ws2);
481
+
482
+ // Now modify external file to create pre-existing dirty state
483
+ writeFileSync(join(ext2, "external-tracked.txt"), "pre-existing dirty content\n", "utf-8");
484
+
485
+ const cfg2 = join(tempRoot, "config-tracked-dirty.json");
486
+ writeFileSync(cfg2, JSON.stringify({
487
+ workspaceRoot: ws2,
488
+ plansDir: ".patchwarden/plans",
489
+ tasksDir: ".patchwarden/tasks",
490
+ agents: {
491
+ noop: { command: process.execPath, args: ["-e", "console.log('noop')"] },
492
+ },
493
+ allowedTestCommands: ["node --check main.js"],
494
+ maxReadFileBytes: 200000,
495
+ defaultTaskTimeoutSeconds: 10,
496
+ watcherStaleSeconds: 3600,
497
+ }, null, 2), "utf-8");
498
+
499
+ const oldConfig = process.env.PATCHWARDEN_CONFIG;
500
+ process.env.PATCHWARDEN_CONFIG = cfg2;
501
+ reloadConfig(cfg2);
502
+
503
+ const plan = savePlan({ title: "Noop with external dirty", content: "Do nothing." });
504
+ const task = createTask({ plan_id: plan.plan_id, agent: "noop", repo_path: "repo", verify_commands: ["node --check main.js"] });
505
+ const result = await runTask(task.task_id);
506
+
507
+ if (result.status !== "done") {
508
+ throw new Error(`Expected done, got: ${JSON.stringify(result)}`);
509
+ }
510
+ const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
511
+
512
+ // Must have pre-existing external dirty warning
513
+ if (!structured.warnings?.some((w) => w.includes("Pre-existing external dirty"))) {
514
+ throw new Error(`Missing pre-existing dirty warning: ${JSON.stringify(structured.warnings)}`);
515
+ }
516
+
517
+ // new_out_of_scope_changes must be empty (file didn't change during task)
518
+ if (structured.new_out_of_scope_changes?.length > 0) {
519
+ throw new Error(`Should have no new out-of-scope changes: ${JSON.stringify(structured.new_out_of_scope_changes)}`);
520
+ }
521
+
522
+ // verify_status should NOT be failed
523
+ if (structured.verify_status === "failed") {
524
+ throw new Error(`verify_status should not be failed: ${structured.verify_status}`);
525
+ }
526
+
527
+ // get_task_summary acceptance_status should NOT be failed
528
+ const summary = getTaskSummary(task.task_id);
529
+ if (summary.acceptance_status === "failed") {
530
+ throw new Error(`acceptance_status should not be failed: ${summary.acceptance_status}`);
531
+ }
532
+
533
+ // audit_task should NOT fail scope_changes
534
+ const audit = auditTask(task.task_id);
535
+ const scopeCheck = audit.checks.find((c) => c.name === "scope_changes");
536
+ if (!scopeCheck || scopeCheck.result !== "pass") {
537
+ throw new Error(`audit scope_changes should pass: ${JSON.stringify(scopeCheck)}`);
538
+ }
539
+
540
+ // Restore config AFTER all assertions
541
+ process.env.PATCHWARDEN_CONFIG = oldConfig;
542
+ reloadConfig(oldConfig);
543
+
544
+ // Print result.json key fields for reporting
545
+ console.log(` [result.json] status=${structured.status}, verify_status=${structured.verify_status}, new_out_of_scope=${JSON.stringify(structured.new_out_of_scope_changes)}, preexisting=${JSON.stringify(structured.preexisting_external_dirty_files)}, warnings=${JSON.stringify(structured.warnings)}`);
546
+ });
547
+
548
+ await test("pre-existing tracked external dirty changed during task → failed_scope_violation", async () => {
549
+ const ws2 = join(tempRoot, "workspace-tracked-dirty2");
550
+ const repo2 = join(ws2, "repo");
551
+ const ext2 = join(ws2, "external");
552
+ mkdirSync(repo2, { recursive: true });
553
+ mkdirSync(ext2, { recursive: true });
554
+ mkdirSync(join(ws2, ".patchwarden"), { recursive: true });
555
+ writeFileSync(join(ws2, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
556
+ status: "running", pid: process.pid, instance_id: "td-watcher2", launcher_pid: process.pid,
557
+ started_at: new Date().toISOString(), last_heartbeat_at: new Date().toISOString(),
558
+ }), "utf-8");
559
+
560
+ writeFileSync(join(repo2, "main.js"), "console.log('main');\n", "utf-8");
561
+ writeFileSync(join(ext2, "external-tracked.txt"), "external tracked content\n", "utf-8");
562
+
563
+ git(["init"], ws2);
564
+ git(["add", "repo/main.js", "external/external-tracked.txt"], ws2);
565
+ git(["-c", "user.name=T", "-c", "user.email=t@e", "commit", "-m", "init"], ws2);
566
+
567
+ // Modify external file BEFORE task (pre-existing dirty)
568
+ writeFileSync(join(ext2, "external-tracked.txt"), "pre-existing dirty content\n", "utf-8");
569
+
570
+ const cfg2 = join(tempRoot, "config-tracked-dirty2.json");
571
+ writeFileSync(cfg2, JSON.stringify({
572
+ workspaceRoot: ws2,
573
+ plansDir: ".patchwarden/plans",
574
+ tasksDir: ".patchwarden/tasks",
575
+ agents: {
576
+ extmod: { command: process.execPath, args: ["-e", "require('fs').appendFileSync('../external/external-tracked.txt',' modified by task\\n')"] },
577
+ },
578
+ allowedTestCommands: ["node --check main.js"],
579
+ maxReadFileBytes: 200000,
580
+ defaultTaskTimeoutSeconds: 10,
581
+ watcherStaleSeconds: 3600,
582
+ }, null, 2), "utf-8");
583
+
584
+ const oldConfig = process.env.PATCHWARDEN_CONFIG;
585
+ process.env.PATCHWARDEN_CONFIG = cfg2;
586
+ reloadConfig(cfg2);
587
+
588
+ const plan = savePlan({ title: "External dirty modifier", content: "Modify external tracked file." });
589
+ const task = createTask({ plan_id: plan.plan_id, agent: "extmod", repo_path: "repo", verify_commands: ["node --check main.js"] });
590
+ const result = await runTask(task.task_id);
591
+
592
+ if (result.status !== "failed_scope_violation") {
593
+ throw new Error(`Expected failed_scope_violation, got: ${JSON.stringify(result)}`);
594
+ }
595
+ const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
596
+
597
+ // new_out_of_scope_changes must contain the external tracked file
598
+ if (!structured.new_out_of_scope_changes?.some((f) => f.path.includes("external-tracked.txt"))) {
599
+ throw new Error(`Missing new out-of-scope change: ${JSON.stringify(structured.new_out_of_scope_changes)}`);
600
+ }
601
+
602
+ // verify_status must be failed
603
+ if (structured.verify_status !== "failed") {
604
+ throw new Error(`verify_status must be failed: ${structured.verify_status}`);
605
+ }
606
+
607
+ // rollback plan must contain the file
608
+ const rollbackPath = join(task.path, "rollback_scope_violation_plan.md");
609
+ if (!existsSync(rollbackPath)) throw new Error("rollback_scope_violation_plan.md missing");
610
+ const rollback = readFileSync(rollbackPath, "utf-8");
611
+ if (!rollback.includes("external-tracked.txt")) {
612
+ throw new Error(`Rollback plan missing external-tracked.txt: ${rollback}`);
613
+ }
614
+
615
+ // get_task_summary acceptance_status must be failed
616
+ const summary = getTaskSummary(task.task_id);
617
+ if (summary.acceptance_status !== "failed") {
618
+ throw new Error(`acceptance_status must be failed: ${summary.acceptance_status}`);
619
+ }
620
+
621
+ // audit_task verdict must be fail
622
+ const audit = auditTask(task.task_id);
623
+ if (audit.verdict !== "fail") {
624
+ throw new Error(`audit verdict must be fail: ${audit.verdict}`);
625
+ }
626
+
627
+ // Restore config AFTER all assertions
628
+ process.env.PATCHWARDEN_CONFIG = oldConfig;
629
+ reloadConfig(oldConfig);
630
+
631
+ console.log(` [result.json] status=${structured.status}, verify_status=${structured.verify_status}, new_out_of_scope=${JSON.stringify(structured.new_out_of_scope_changes)}, preexisting=${JSON.stringify(structured.preexisting_external_dirty_files)}, warnings=${JSON.stringify(structured.warnings)}`);
632
+ });
633
+
634
+ await test("clean tracked external file changed during task → failed_scope_violation", async () => {
635
+ const ws2 = join(tempRoot, "workspace-tracked-dirty3");
636
+ const repo2 = join(ws2, "repo");
637
+ const ext2 = join(ws2, "external");
638
+ mkdirSync(repo2, { recursive: true });
639
+ mkdirSync(ext2, { recursive: true });
640
+ mkdirSync(join(ws2, ".patchwarden"), { recursive: true });
641
+ writeFileSync(join(ws2, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
642
+ status: "running", pid: process.pid, instance_id: "td-watcher3", launcher_pid: process.pid,
643
+ started_at: new Date().toISOString(), last_heartbeat_at: new Date().toISOString(),
644
+ }), "utf-8");
645
+
646
+ writeFileSync(join(repo2, "main.js"), "console.log('main');\n", "utf-8");
647
+ writeFileSync(join(ext2, "external-tracked.txt"), "external tracked content\n", "utf-8");
648
+
649
+ git(["init"], ws2);
650
+ git(["add", "repo/main.js", "external/external-tracked.txt"], ws2);
651
+ git(["-c", "user.name=T", "-c", "user.email=t@e", "commit", "-m", "init"], ws2);
652
+
653
+ // External file is clean (committed state) — no pre-existing dirty
654
+
655
+ const cfg2 = join(tempRoot, "config-tracked-dirty3.json");
656
+ writeFileSync(cfg2, JSON.stringify({
657
+ workspaceRoot: ws2,
658
+ plansDir: ".patchwarden/plans",
659
+ tasksDir: ".patchwarden/tasks",
660
+ agents: {
661
+ extmod: { command: process.execPath, args: ["-e", "require('fs').appendFileSync('../external/external-tracked.txt',' modified by task\\n')"] },
662
+ },
663
+ allowedTestCommands: ["node --check main.js"],
664
+ maxReadFileBytes: 200000,
665
+ defaultTaskTimeoutSeconds: 10,
666
+ watcherStaleSeconds: 3600,
667
+ }, null, 2), "utf-8");
668
+
669
+ const oldConfig = process.env.PATCHWARDEN_CONFIG;
670
+ process.env.PATCHWARDEN_CONFIG = cfg2;
671
+ reloadConfig(cfg2);
672
+
673
+ const plan = savePlan({ title: "Clean external modifier", content: "Modify clean external tracked file." });
674
+ const task = createTask({ plan_id: plan.plan_id, agent: "extmod", repo_path: "repo", verify_commands: ["node --check main.js"] });
675
+ const result = await runTask(task.task_id);
676
+
677
+ if (result.status !== "failed_scope_violation") {
678
+ throw new Error(`Expected failed_scope_violation, got: ${JSON.stringify(result)}`);
679
+ }
680
+ const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
681
+
682
+ // new_out_of_scope_changes must contain the external tracked file (clean→dirty)
683
+ if (!structured.new_out_of_scope_changes?.some((f) => f.path.includes("external-tracked.txt"))) {
684
+ throw new Error(`Missing new out-of-scope change: ${JSON.stringify(structured.new_out_of_scope_changes)}`);
685
+ }
686
+
687
+ // verify_status must be failed
688
+ if (structured.verify_status !== "failed") {
689
+ throw new Error(`verify_status must be failed: ${structured.verify_status}`);
690
+ }
691
+
692
+ // get_task_summary acceptance_status must be failed
693
+ const summary = getTaskSummary(task.task_id);
694
+ if (summary.acceptance_status !== "failed") {
695
+ throw new Error(`acceptance_status must be failed: ${summary.acceptance_status}`);
696
+ }
697
+
698
+ // audit_task verdict must be fail
699
+ const audit = auditTask(task.task_id);
700
+ if (audit.verdict !== "fail") {
701
+ throw new Error(`audit verdict must be fail: ${audit.verdict}`);
702
+ }
703
+
704
+ // Restore config AFTER all assertions
705
+ process.env.PATCHWARDEN_CONFIG = oldConfig;
706
+ reloadConfig(oldConfig);
707
+
708
+ console.log(` [result.json] status=${structured.status}, verify_status=${structured.verify_status}, new_out_of_scope=${JSON.stringify(structured.new_out_of_scope_changes)}, preexisting=${JSON.stringify(structured.preexisting_external_dirty_files)}, warnings=${JSON.stringify(structured.warnings)}`);
709
+ });
710
+
711
+ await test("tracked external file rename → failed_scope_violation", async () => {
712
+ const ws2 = join(tempRoot, "workspace-tracked-rename");
713
+ const repo2 = join(ws2, "repo");
714
+ const ext2 = join(ws2, "external");
715
+ mkdirSync(repo2, { recursive: true });
716
+ mkdirSync(ext2, { recursive: true });
717
+ mkdirSync(join(ws2, ".patchwarden"), { recursive: true });
718
+ writeFileSync(join(ws2, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
719
+ status: "running", pid: process.pid, instance_id: "td-watcher-rename", launcher_pid: process.pid,
720
+ started_at: new Date().toISOString(), last_heartbeat_at: new Date().toISOString(),
721
+ }), "utf-8");
722
+
723
+ writeFileSync(join(repo2, "main.js"), "console.log('main');\n", "utf-8");
724
+ writeFileSync(join(ext2, "external-tracked.txt"), "external tracked content\n", "utf-8");
725
+
726
+ git(["init"], ws2);
727
+ git(["add", "repo/main.js", "external/external-tracked.txt"], ws2);
728
+ git(["-c", "user.name=T", "-c", "user.email=t@e", "commit", "-m", "init"], ws2);
729
+
730
+ // External file is clean (committed state) — no pre-existing dirty
731
+
732
+ const cfg2 = join(tempRoot, "config-tracked-rename.json");
733
+ writeFileSync(cfg2, JSON.stringify({
734
+ workspaceRoot: ws2,
735
+ plansDir: ".patchwarden/plans",
736
+ tasksDir: ".patchwarden/tasks",
737
+ agents: {
738
+ extrenamer: { command: process.execPath, args: ["-e", "require('fs').renameSync('../external/external-tracked.txt','../external/external-renamed.txt')"] },
739
+ },
740
+ allowedTestCommands: ["node --check main.js"],
741
+ maxReadFileBytes: 200000,
742
+ defaultTaskTimeoutSeconds: 10,
743
+ watcherStaleSeconds: 3600,
744
+ }, null, 2), "utf-8");
745
+
746
+ const oldConfig = process.env.PATCHWARDEN_CONFIG;
747
+ process.env.PATCHWARDEN_CONFIG = cfg2;
748
+ reloadConfig(cfg2);
749
+
750
+ const plan = savePlan({ title: "External rename", content: "Rename external tracked file." });
751
+ const task = createTask({ plan_id: plan.plan_id, agent: "extrenamer", repo_path: "repo", verify_commands: ["node --check main.js"] });
752
+ const result = await runTask(task.task_id);
753
+
754
+ if (result.status !== "failed_scope_violation") {
755
+ throw new Error(`Expected failed_scope_violation, got: ${JSON.stringify(result)}`);
756
+ }
757
+ const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
758
+
759
+ // new_out_of_scope_changes must contain rename evidence (old or new path)
760
+ const hasRenameEvidence = structured.new_out_of_scope_changes?.some(
761
+ (f) => f.path.includes("external-renamed.txt") || f.path.includes("external-tracked.txt") || f.change === "renamed"
762
+ );
763
+ if (!hasRenameEvidence) {
764
+ throw new Error(`Missing rename evidence in new_out_of_scope_changes: ${JSON.stringify(structured.new_out_of_scope_changes)}`);
765
+ }
766
+
767
+ // verify_status must be failed
768
+ if (structured.verify_status !== "failed") {
769
+ throw new Error(`verify_status must be failed: ${structured.verify_status}`);
770
+ }
771
+
772
+ // get_task_summary acceptance_status must be failed
773
+ const summary = getTaskSummary(task.task_id);
774
+ if (summary.acceptance_status !== "failed") {
775
+ throw new Error(`acceptance_status must be failed: ${summary.acceptance_status}`);
776
+ }
777
+
778
+ // audit_task verdict must be fail
779
+ const audit = auditTask(task.task_id);
780
+ if (audit.verdict !== "fail") {
781
+ throw new Error(`audit verdict must be fail: ${audit.verdict}`);
782
+ }
783
+
784
+ // Restore config AFTER all assertions
785
+ process.env.PATCHWARDEN_CONFIG = oldConfig;
786
+ reloadConfig(oldConfig);
787
+
788
+ console.log(` [result.json] status=${structured.status}, verify_status=${structured.verify_status}, new_out_of_scope=${JSON.stringify(structured.new_out_of_scope_changes)}, preexisting=${JSON.stringify(structured.preexisting_external_dirty_files)}, warnings=${JSON.stringify(structured.warnings)}`);
789
+ });
790
+
401
791
  await test("timeout terminates a long-running agent", async () => {
402
792
  const plan = savePlan({ title: "Timeout", content: "Wait." });
403
793
  const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 1 });
@@ -423,7 +813,7 @@ try {
423
813
  if (!request.cancel_requested || request.force_kill_requested) {
424
814
  throw new Error(`Unexpected cancel response: ${JSON.stringify(request)}`);
425
815
  }
426
- const result = await running;
816
+ const result = await raceWithTimeout(running, 15000, "cancel_task did not terminate within 15s");
427
817
  if (result.status !== "canceled") throw new Error(`Expected canceled, got ${JSON.stringify(result)}`);
428
818
  });
429
819
 
@@ -434,7 +824,7 @@ try {
434
824
  await waitForRunning(getTaskStatus, task.task_id);
435
825
  const request = killTask(task.task_id);
436
826
  if (!request.force_kill_requested) throw new Error(`Unexpected kill response: ${JSON.stringify(request)}`);
437
- const result = await running;
827
+ const result = await raceWithTimeout(running, 15000, "kill_task did not terminate within 15s");
438
828
  if (result.status !== "canceled" || !result.error?.includes("kill_task")) {
439
829
  throw new Error(`Expected killed/canceled result, got ${JSON.stringify(result)}`);
440
830
  }
@@ -451,3 +841,7 @@ console.log(`${"=".repeat(50)}\n`);
451
841
 
452
842
  if (failed > 0) process.exit(1);
453
843
  console.log("ALL LIFECYCLE TESTS PASSED\n");
844
+
845
+ // Safety: force exit after cleanup in case any stray timer/handle keeps the event loop alive.
846
+ // All child processes are killed by the runner; this only guards against OS-level pipe drains.
847
+ process.exit(0);