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
@@ -11,8 +11,8 @@ if (process.platform !== "win32") {
11
11
  }
12
12
 
13
13
  const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
14
- const root = resolve(scriptDir, "..");
15
- const manager = join(scriptDir, "manage-patchwarden.ps1");
14
+ const root = resolve(scriptDir, "..", "..");
15
+ const manager = join(root, "scripts", "control", "manage-patchwarden.ps1");
16
16
  const temp = mkdtempSync(join(tmpdir(), "patchwarden-control-smoke-"));
17
17
  const mockConfig = join(temp, "patchwarden.config.json");
18
18
  writeFileSync(mockConfig, JSON.stringify({
@@ -139,7 +139,13 @@ try {
139
139
 
140
140
  const expectedFiles = [
141
141
  "PatchWarden.cmd",
142
- "scripts/manage-patchwarden.ps1",
142
+ "PatchWarden-Control.cmd",
143
+ "PatchWarden-Control-Tray.cmd",
144
+ "PatchWarden-Desktop.cmd",
145
+ "Restart-PatchWarden-Control.cmd",
146
+ "Stop-PatchWarden.cmd",
147
+ "scripts/control/manage-patchwarden.ps1",
148
+ "scripts/control/stop-patchwarden.ps1",
143
149
  "scripts/launchers/Start-PatchWarden-Tunnel.cmd",
144
150
  "scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd",
145
151
  ];
@@ -152,6 +158,14 @@ try {
152
158
  if (!rootEntry.includes("manage-patchwarden.ps1")) {
153
159
  throw new Error("PatchWarden.cmd does not invoke the consolidated manager");
154
160
  }
161
+ const stopEntry = readFileSync(join(root, "Stop-PatchWarden.cmd"), "utf8");
162
+ if (!stopEntry.includes("scripts\\control\\stop-patchwarden.ps1")) {
163
+ throw new Error("Stop-PatchWarden.cmd does not invoke the one-click shutdown script");
164
+ }
165
+ const desktopEntry = readFileSync(join(root, "PatchWarden-Desktop.cmd"), "utf8");
166
+ if (!desktopEntry.includes("control-center-tray.ps1") || !desktopEntry.includes("WindowStyle Hidden")) {
167
+ throw new Error("PatchWarden-Desktop.cmd must launch the tray hidden as the daily desktop entry");
168
+ }
155
169
  console.log("ok - control handles orphan cleanup, scoped kill, port conflicts, health fallback, and Core/Direct lifecycle actions");
156
170
  } finally {
157
171
  if (fakeTunnel?.exitCode === null) fakeTunnel.kill();
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
- const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
8
+ const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
9
9
  const tempRoot = mkdtempSync(join(tmpdir(), "patchwarden-doctor-"));
10
10
  const workspaceRoot = join(tempRoot, "workspace");
11
11
  const configPath = join(tempRoot, "patchwarden.config.json");
@@ -11,10 +11,10 @@ import { tmpdir } from "node:os";
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { setTimeout as sleep } from "node:timers/promises";
14
- import { CHATGPT_CORE_TOOL_NAMES } from "../dist/tools/toolCatalog.js";
14
+ import { CHATGPT_CORE_TOOL_NAMES } from "../../dist/tools/toolCatalog.js";
15
15
 
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
- const root = resolve(__dirname, "..");
17
+ const root = resolve(__dirname, "..", "..");
18
18
  const serverPath = resolve(root, "dist", "httpServer.js");
19
19
 
20
20
  const host = "127.0.0.1";
@@ -139,7 +139,7 @@ try {
139
139
  serverStderr += chunk.toString();
140
140
  });
141
141
 
142
- await sleep(1000);
142
+ await sleep(3000);
143
143
  if (serverProcess.exitCode !== null) {
144
144
  throw new Error(`HTTP server exited early: ${serverStderr}`);
145
145
  }
@@ -274,7 +274,15 @@ try {
274
274
  stdio: ["ignore", "ignore", "pipe"],
275
275
  });
276
276
  serverProcess.stderr.on("data", (chunk) => { serverStderr += chunk.toString(); });
277
- await sleep(1000);
277
+ await sleep(3000);
278
+ // Wait for server to be ready
279
+ for (let i = 0; i < 10; i++) {
280
+ try {
281
+ const r = await fetch(`${mcpUrl}/healthz`);
282
+ if (r.status === 200) break;
283
+ } catch {}
284
+ await sleep(500);
285
+ }
278
286
 
279
287
  await test("token: no token returns 401", async () => {
280
288
  try {
@@ -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
 
@@ -148,17 +148,18 @@ try {
148
148
  );
149
149
  process.env.PATCHWARDEN_CONFIG = configPath;
150
150
 
151
- const { savePlan } = await import("../dist/tools/savePlan.js");
152
- const { createTask } = await import("../dist/tools/createTask.js");
153
- const { getTaskStatus } = await import("../dist/tools/getTaskStatus.js");
154
- const { cancelTask } = await import("../dist/tools/cancelTask.js");
155
- const { killTask } = await import("../dist/tools/killTask.js");
156
- const { getDiff } = await import("../dist/tools/taskOutputs.js");
157
- const { listAgents } = await import("../dist/tools/listAgents.js");
158
- const { getTaskSummary } = await import("../dist/tools/getTaskSummary.js");
159
- const { auditTask } = await import("../dist/tools/auditTask.js");
160
- const { waitForTask } = await import("../dist/tools/waitForTask.js");
161
- const { runTask } = await import("../dist/runner/runTask.js");
151
+ const { savePlan } = await import("../../dist/tools/savePlan.js");
152
+ const { createTask } = await import("../../dist/tools/createTask.js");
153
+ const { getTaskStatus } = await import("../../dist/tools/getTaskStatus.js");
154
+ const { cancelTask } = await import("../../dist/tools/cancelTask.js");
155
+ const { killTask } = await import("../../dist/tools/killTask.js");
156
+ const { getDiff } = await import("../../dist/tools/taskOutputs.js");
157
+ const { listAgents } = await import("../../dist/tools/listAgents.js");
158
+ const { getTaskSummary } = await import("../../dist/tools/getTaskSummary.js");
159
+ const { auditTask } = await import("../../dist/tools/auditTask.js");
160
+ const { waitForTask } = await import("../../dist/tools/waitForTask.js");
161
+ const { runTask } = await import("../../dist/runner/runTask.js");
162
+ const { reloadConfig } = await import("../../dist/config.js");
162
163
 
163
164
  await test("list_agents reports configured executables", async () => {
164
165
  const result = listAgents();
@@ -454,6 +455,339 @@ try {
454
455
  }
455
456
  });
456
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
+
457
791
  await test("timeout terminates a long-running agent", async () => {
458
792
  const plan = savePlan({ title: "Timeout", content: "Wait." });
459
793
  const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 1 });
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync, rmSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
6
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
7
- import { CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES } from "../dist/tools/toolCatalog.js";
7
+ import { CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES } from "../../dist/tools/toolCatalog.js";
8
8
 
9
- const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
9
+ const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
10
10
  const jsonOnly = process.argv.includes("--json");
11
11
  const profileIndex = process.argv.indexOf("--profile");
12
12
  const profile = profileIndex >= 0 ? process.argv[profileIndex + 1] : "chatgpt_core";
@@ -21,12 +21,32 @@ const transportEnv = {
21
21
  ...process.env,
22
22
  PATCHWARDEN_TOOL_PROFILE: profile,
23
23
  };
24
- if (process.env.PATCHWARDEN_CONFIG) {
25
- transportEnv.PATCHWARDEN_CONFIG = process.env.PATCHWARDEN_CONFIG;
26
- } else if (existsSync(defaultConfigPath)) {
27
- transportEnv.PATCHWARDEN_CONFIG = defaultConfigPath;
28
- } else {
29
- delete transportEnv.PATCHWARDEN_CONFIG;
24
+
25
+ // When checking chatgpt_direct profile, ensure enableDirectProfile is true
26
+ // by creating a temporary config if needed.
27
+ let tempConfigPath = null;
28
+ if (profile === "chatgpt_direct") {
29
+ const baseConfig = existsSync(defaultConfigPath)
30
+ ? JSON.parse(readFileSync(defaultConfigPath, "utf-8"))
31
+ : {};
32
+ if (!baseConfig.enableDirectProfile) {
33
+ const { mkdtempSync, writeFileSync, rmSync } = await import("node:fs");
34
+ const { tmpdir } = await import("node:os");
35
+ const { join } = await import("node:path");
36
+ tempConfigPath = join(mkdtempSync(join(tmpdir(), "pw-direct-")), "config.json");
37
+ writeFileSync(tempConfigPath, JSON.stringify({ ...baseConfig, enableDirectProfile: true }, null, 2), "utf-8");
38
+ transportEnv.PATCHWARDEN_CONFIG = tempConfigPath;
39
+ }
40
+ }
41
+
42
+ if (!transportEnv.PATCHWARDEN_CONFIG) {
43
+ if (process.env.PATCHWARDEN_CONFIG) {
44
+ transportEnv.PATCHWARDEN_CONFIG = process.env.PATCHWARDEN_CONFIG;
45
+ } else if (existsSync(defaultConfigPath)) {
46
+ transportEnv.PATCHWARDEN_CONFIG = defaultConfigPath;
47
+ } else {
48
+ delete transportEnv.PATCHWARDEN_CONFIG;
49
+ }
30
50
  }
31
51
 
32
52
  const transport = new StdioClientTransport({
@@ -159,4 +179,7 @@ try {
159
179
  process.exitCode = 1;
160
180
  } finally {
161
181
  await client.close().catch(() => {});
182
+ if (tempConfigPath) {
183
+ try { rmSync(tempConfigPath, { force: true }); } catch {}
184
+ }
162
185
  }
@@ -14,7 +14,9 @@ import { fileURLToPath } from "node:url";
14
14
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
15
15
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
16
16
 
17
- const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
17
+ const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
18
+ const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
19
+ const expectedServerVersion = packageJson.version;
18
20
  const tempRoot = mkdtempSync(join(tmpdir(), "patchwarden-mcp-"));
19
21
  const workspaceRoot = join(tempRoot, "workspace");
20
22
  const configPath = join(tempRoot, "patchwarden.config.json");
@@ -126,8 +128,10 @@ try {
126
128
  "read_workspace_file",
127
129
  "retry_task",
128
130
  "run_verification",
131
+ "safe_status",
129
132
  "save_plan",
130
133
  "search_workspace",
134
+ "sync_file",
131
135
  "wait_for_task",
132
136
  ];
133
137
  if (JSON.stringify(names) !== JSON.stringify(expected)) {
@@ -139,7 +143,7 @@ try {
139
143
  if (!tools._meta || typeof tools._meta.tool_manifest_sha256 !== "string" || tools._meta.tool_manifest_sha256.length !== 64) {
140
144
  throw new Error(`tools/list _meta missing manifest hash: ${JSON.stringify(tools._meta || null)}`);
141
145
  }
142
- if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 28) {
146
+ if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 30) {
143
147
  throw new Error(`tools/list _meta profile/count mismatch: ${JSON.stringify(tools._meta)}`);
144
148
  }
145
149
  if (typeof tools._meta.schema_epoch !== "string" || typeof tools._meta.server_version !== "string") {
@@ -210,7 +214,7 @@ try {
210
214
  test_command: "npm test",
211
215
  });
212
216
  if (
213
- task.server_version !== "0.6.0" ||
217
+ task.server_version !== expectedServerVersion ||
214
218
  !/^[a-f0-9]{64}$/.test(task.tool_manifest_sha256 || "") ||
215
219
  task.next_tool_call?.name !== "wait_for_task" ||
216
220
  task.next_tool_call?.arguments?.timeout_seconds !== 25
@@ -389,7 +393,7 @@ try {
389
393
  await disabledClient.close();
390
394
  ok("chatgpt_direct disabled exposes only health_check with diagnostic");
391
395
 
392
- // 2. chatgpt_direct enabled: 9 tools + minimal create_direct_session
396
+ // 2. chatgpt_direct enabled: 10 tools + minimal create_direct_session
393
397
  const enabledConfigPath = join(tempRoot, "direct-enabled.json");
394
398
  const directRepo = join(workspaceRoot, "direct-fixture");
395
399
  mkdirSync(join(directRepo, "src"), { recursive: true });
@@ -447,12 +451,13 @@ try {
447
451
  "read_workspace_file",
448
452
  "run_verification",
449
453
  "search_workspace",
454
+ "sync_file",
450
455
  ];
451
456
  if (JSON.stringify(enabledNames) !== JSON.stringify(expectedDirect)) {
452
457
  throw new Error(`chatgpt_direct enabled tools mismatch: ${enabledNames.join(", ")}`);
453
458
  }
454
- if (enabledTools._meta.tool_count !== 9) {
455
- throw new Error(`chatgpt_direct enabled tool_count should be 9, got ${enabledTools._meta.tool_count}`);
459
+ if (enabledTools._meta.tool_count !== 10) {
460
+ throw new Error(`chatgpt_direct enabled tool_count should be 10, got ${enabledTools._meta.tool_count}`);
456
461
  }
457
462
 
458
463
  // Minimal create_direct_session
@@ -472,12 +477,12 @@ try {
472
477
  if (enabledHealth.direct_profile_enabled !== true) {
473
478
  throw new Error(`direct_profile_enabled should be true, got ${enabledHealth.direct_profile_enabled}`);
474
479
  }
475
- if (enabledHealth.direct_tool_count !== 9) {
476
- throw new Error(`direct_tool_count should be 9, got ${enabledHealth.direct_tool_count}`);
480
+ if (enabledHealth.direct_tool_count !== 10) {
481
+ throw new Error(`direct_tool_count should be 10, got ${enabledHealth.direct_tool_count}`);
477
482
  }
478
483
 
479
484
  await enabledClient.close();
480
- ok("chatgpt_direct enabled exposes 9 tools and create_direct_session works");
485
+ ok("chatgpt_direct enabled exposes 10 tools and create_direct_session works");
481
486
  } catch (error) {
482
487
  fail("MCP smoke test", error);
483
488
  } finally {
@@ -45,7 +45,8 @@ if (leaked.length > 0) {
45
45
 
46
46
  const required = [
47
47
  "PatchWarden.cmd",
48
- "scripts/manage-patchwarden.ps1",
48
+ "PatchWarden-Desktop.cmd",
49
+ "scripts/control/manage-patchwarden.ps1",
49
50
  "scripts/launchers/Start-PatchWarden-Tunnel.cmd",
50
51
  "scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd",
51
52
  ];
@@ -10,7 +10,7 @@ if (process.platform !== "win32") {
10
10
  process.exit(0);
11
11
  }
12
12
 
13
- const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
13
+ const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
14
14
  const temp = mkdtempSync(join(tmpdir(), "patchwarden-tunnel-smoke-"));
15
15
  const mockJs = join(temp, "mock-tunnel-client.js");
16
16
  const mockCmd = join(temp, "mock-tunnel-client.cmd");
@@ -57,7 +57,7 @@ if(command==='run'){
57
57
  MOCK_TUNNEL_STATE: stateFile,
58
58
  };
59
59
  const result = spawnSync("powershell.exe", [
60
- "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(root, "scripts", "start-patchwarden-tunnel.ps1"),
60
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", join(root, "scripts", "control", "start-patchwarden-tunnel.ps1"),
61
61
  "-TunnelId", "tunnel_smoke_fixture",
62
62
  "-TunnelClientExe", mockCmd,
63
63
  "-ConfigPath", mockConfig,