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
@@ -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
 
@@ -159,6 +159,7 @@ try {
159
159
  const { auditTask } = await import("../dist/tools/auditTask.js");
160
160
  const { waitForTask } = await import("../dist/tools/waitForTask.js");
161
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,5 +1,5 @@
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";
@@ -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
  }
@@ -126,8 +126,10 @@ try {
126
126
  "read_workspace_file",
127
127
  "retry_task",
128
128
  "run_verification",
129
+ "safe_status",
129
130
  "save_plan",
130
131
  "search_workspace",
132
+ "sync_file",
131
133
  "wait_for_task",
132
134
  ];
133
135
  if (JSON.stringify(names) !== JSON.stringify(expected)) {
@@ -139,7 +141,7 @@ try {
139
141
  if (!tools._meta || typeof tools._meta.tool_manifest_sha256 !== "string" || tools._meta.tool_manifest_sha256.length !== 64) {
140
142
  throw new Error(`tools/list _meta missing manifest hash: ${JSON.stringify(tools._meta || null)}`);
141
143
  }
142
- if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 28) {
144
+ if (tools._meta.tool_profile !== "full" || tools._meta.tool_count !== 30) {
143
145
  throw new Error(`tools/list _meta profile/count mismatch: ${JSON.stringify(tools._meta)}`);
144
146
  }
145
147
  if (typeof tools._meta.schema_epoch !== "string" || typeof tools._meta.server_version !== "string") {
@@ -210,7 +212,7 @@ try {
210
212
  test_command: "npm test",
211
213
  });
212
214
  if (
213
- task.server_version !== "0.6.0" ||
215
+ task.server_version !== "0.6.1" ||
214
216
  !/^[a-f0-9]{64}$/.test(task.tool_manifest_sha256 || "") ||
215
217
  task.next_tool_call?.name !== "wait_for_task" ||
216
218
  task.next_tool_call?.arguments?.timeout_seconds !== 25
@@ -389,7 +391,7 @@ try {
389
391
  await disabledClient.close();
390
392
  ok("chatgpt_direct disabled exposes only health_check with diagnostic");
391
393
 
392
- // 2. chatgpt_direct enabled: 9 tools + minimal create_direct_session
394
+ // 2. chatgpt_direct enabled: 10 tools + minimal create_direct_session
393
395
  const enabledConfigPath = join(tempRoot, "direct-enabled.json");
394
396
  const directRepo = join(workspaceRoot, "direct-fixture");
395
397
  mkdirSync(join(directRepo, "src"), { recursive: true });
@@ -447,12 +449,13 @@ try {
447
449
  "read_workspace_file",
448
450
  "run_verification",
449
451
  "search_workspace",
452
+ "sync_file",
450
453
  ];
451
454
  if (JSON.stringify(enabledNames) !== JSON.stringify(expectedDirect)) {
452
455
  throw new Error(`chatgpt_direct enabled tools mismatch: ${enabledNames.join(", ")}`);
453
456
  }
454
- if (enabledTools._meta.tool_count !== 9) {
455
- throw new Error(`chatgpt_direct enabled tool_count should be 9, got ${enabledTools._meta.tool_count}`);
457
+ if (enabledTools._meta.tool_count !== 10) {
458
+ throw new Error(`chatgpt_direct enabled tool_count should be 10, got ${enabledTools._meta.tool_count}`);
456
459
  }
457
460
 
458
461
  // Minimal create_direct_session
@@ -472,12 +475,12 @@ try {
472
475
  if (enabledHealth.direct_profile_enabled !== true) {
473
476
  throw new Error(`direct_profile_enabled should be true, got ${enabledHealth.direct_profile_enabled}`);
474
477
  }
475
- if (enabledHealth.direct_tool_count !== 9) {
476
- throw new Error(`direct_tool_count should be 9, got ${enabledHealth.direct_tool_count}`);
478
+ if (enabledHealth.direct_tool_count !== 10) {
479
+ throw new Error(`direct_tool_count should be 10, got ${enabledHealth.direct_tool_count}`);
477
480
  }
478
481
 
479
482
  await enabledClient.close();
480
- ok("chatgpt_direct enabled exposes 9 tools and create_direct_session works");
483
+ ok("chatgpt_direct enabled exposes 10 tools and create_direct_session works");
481
484
  } catch (error) {
482
485
  fail("MCP smoke test", error);
483
486
  } finally {
@@ -3,11 +3,14 @@ import {
3
3
  cpSync,
4
4
  existsSync,
5
5
  mkdirSync,
6
+ readFileSync,
6
7
  readdirSync,
7
8
  rmSync,
8
9
  statSync,
10
+ writeFileSync,
9
11
  } from "node:fs";
10
12
  import { execFileSync } from "node:child_process";
13
+ import { deflateRawSync } from "node:zlib";
11
14
  import { join, relative, resolve } from "node:path";
12
15
  import { fileURLToPath } from "node:url";
13
16
 
@@ -16,6 +19,10 @@ const root = resolve(scriptDir, "..");
16
19
  const releaseDir = resolve(root, "release");
17
20
  const archivePath = resolve(root, "patchwarden-release.tar.gz");
18
21
 
22
+ // Read version from package.json for zip naming
23
+ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8"));
24
+ const zipArchivePath = resolve(root, `PatchWarden-v${pkg.version}.zip`);
25
+
19
26
  const include = [
20
27
  "dist",
21
28
  "docs",
@@ -45,6 +52,119 @@ const forbidden = [
45
52
  /(^|[\\/])kill-patchwarden\.(cmd|ps1)$/i,
46
53
  ];
47
54
 
55
+ // ── Minimal zip writer helpers (POSIX paths, DEFLATE via zlib) ────
56
+ // Defined before main execution so const CRC_TABLE is initialized.
57
+
58
+ const CRC_TABLE = new Uint32Array(256);
59
+ for (let i = 0; i < 256; i++) {
60
+ let c = i;
61
+ for (let j = 0; j < 8; j++) {
62
+ c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
63
+ }
64
+ CRC_TABLE[i] = c;
65
+ }
66
+
67
+ function crc32(buf) {
68
+ let c = 0xFFFFFFFF;
69
+ for (let i = 0; i < buf.length; i++) {
70
+ c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
71
+ }
72
+ return (c ^ 0xFFFFFFFF) >>> 0;
73
+ }
74
+
75
+ function dosDateTime(date) {
76
+ const dosTime = (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1);
77
+ const dosDate = (((date.getFullYear() - 1980) & 0x7F) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
78
+ return { dosTime, dosDate };
79
+ }
80
+
81
+ function createZip(files, outputPath) {
82
+ const localParts = [];
83
+ const centralParts = [];
84
+ let offset = 0;
85
+ const now = new Date();
86
+ const { dosTime, dosDate } = dosDateTime(now);
87
+
88
+ for (const file of files) {
89
+ const posixPath = file.path.replace(/\\/g, "/");
90
+ const nameBuf = Buffer.from(posixPath, "utf-8");
91
+ const content = file.content;
92
+ const compressed = deflateRawSync(content);
93
+ const useDeflate = compressed.length < content.length;
94
+ const method = useDeflate ? 8 : 0;
95
+ const data = useDeflate ? compressed : content;
96
+ const crc = crc32(content);
97
+
98
+ const lh = Buffer.alloc(30);
99
+ lh.writeUInt32LE(0x04034b50, 0);
100
+ lh.writeUInt16LE(20, 4);
101
+ lh.writeUInt16LE(0, 6);
102
+ lh.writeUInt16LE(method, 8);
103
+ lh.writeUInt16LE(dosTime, 10);
104
+ lh.writeUInt16LE(dosDate, 12);
105
+ lh.writeUInt32LE(crc, 14);
106
+ lh.writeUInt32LE(data.length, 18);
107
+ lh.writeUInt32LE(content.length, 22);
108
+ lh.writeUInt16LE(nameBuf.length, 26);
109
+ lh.writeUInt16LE(0, 28);
110
+
111
+ const localHeaderOffset = offset;
112
+ localParts.push(Buffer.concat([lh, nameBuf, data]));
113
+ offset += lh.length + nameBuf.length + data.length;
114
+
115
+ const cd = Buffer.alloc(46);
116
+ cd.writeUInt32LE(0x02014b50, 0);
117
+ cd.writeUInt16LE(20, 4);
118
+ cd.writeUInt16LE(20, 6);
119
+ cd.writeUInt16LE(0, 8);
120
+ cd.writeUInt16LE(method, 10);
121
+ cd.writeUInt16LE(dosTime, 12);
122
+ cd.writeUInt16LE(dosDate, 14);
123
+ cd.writeUInt32LE(crc, 16);
124
+ cd.writeUInt32LE(data.length, 20);
125
+ cd.writeUInt32LE(content.length, 24);
126
+ cd.writeUInt16LE(nameBuf.length, 28);
127
+ cd.writeUInt16LE(0, 30);
128
+ cd.writeUInt16LE(0, 32);
129
+ cd.writeUInt16LE(0, 34);
130
+ cd.writeUInt16LE(0, 36);
131
+ cd.writeUInt32LE(0, 38);
132
+ cd.writeUInt32LE(localHeaderOffset, 42);
133
+ centralParts.push(Buffer.concat([cd, nameBuf]));
134
+ }
135
+
136
+ const centralDir = Buffer.concat(centralParts);
137
+ const centralDirOffset = offset;
138
+
139
+ const eocd = Buffer.alloc(22);
140
+ eocd.writeUInt32LE(0x06054b50, 0);
141
+ eocd.writeUInt16LE(0, 4);
142
+ eocd.writeUInt16LE(0, 6);
143
+ eocd.writeUInt16LE(files.length, 8);
144
+ eocd.writeUInt16LE(files.length, 10);
145
+ eocd.writeUInt32LE(centralDir.length, 12);
146
+ eocd.writeUInt32LE(centralDirOffset, 16);
147
+ eocd.writeUInt16LE(0, 20);
148
+
149
+ writeFileSync(outputPath, Buffer.concat([...localParts, centralDir, eocd]));
150
+ }
151
+
152
+ function readZipEntryNames(zipPath) {
153
+ const buf = readFileSync(zipPath);
154
+ const names = [];
155
+ for (let i = 0; i < buf.length - 4; i++) {
156
+ if (buf.readUInt32LE(i) === 0x02014b50) {
157
+ const nameLen = buf.readUInt16LE(i + 28);
158
+ const extraLen = buf.readUInt16LE(i + 30);
159
+ const commentLen = buf.readUInt16LE(i + 32);
160
+ const name = buf.toString("utf-8", i + 46, i + 46 + nameLen);
161
+ names.push(name);
162
+ i += 45 + nameLen + extraLen + commentLen;
163
+ }
164
+ }
165
+ return names;
166
+ }
167
+
48
168
  console.log("[pack-clean] Preparing clean release directory...");
49
169
  rmSync(releaseDir, { recursive: true, force: true });
50
170
  rmSync(archivePath, { force: true });
@@ -115,7 +235,43 @@ if (badArchiveEntries.length > 0) {
115
235
 
116
236
  const sizeKb = (statSync(archivePath).size / 1024).toFixed(1);
117
237
  console.log(`[pack-clean] Release directory: ${releaseFiles.length} files`);
118
- console.log(`[pack-clean] Archive: ${archivePath} (${sizeKb} KB)`);
238
+ console.log(`[pack-clean] tar.gz Archive: ${archivePath} (${sizeKb} KB)`);
239
+
240
+ // ── Create zip archive with POSIX path separators ─────────────────
241
+ console.log("[pack-clean] Creating zip archive with POSIX paths...");
242
+ rmSync(zipArchivePath, { force: true });
243
+
244
+ const zipFiles = releaseFiles.map((file) => {
245
+ const rel = toPosix(relative(releaseDir, file));
246
+ return { path: rel, content: readFileSync(file) };
247
+ });
248
+
249
+ createZip(zipFiles, zipArchivePath);
250
+
251
+ // Verify zip entries use POSIX separators
252
+ const zipEntryNames = readZipEntryNames(zipArchivePath);
253
+ const badZipEntries = zipEntryNames.filter((name) => name.includes("\\"));
254
+ if (badZipEntries.length > 0) {
255
+ console.error("[pack-clean] Backslash path separators found in zip entries:");
256
+ for (const name of badZipEntries.slice(0, 20)) {
257
+ console.error(` ${name}`);
258
+ }
259
+ rmSync(zipArchivePath, { force: true });
260
+ process.exit(1);
261
+ }
262
+
263
+ const badZipForbidden = zipEntryNames.filter(isForbidden);
264
+ if (badZipForbidden.length > 0) {
265
+ console.error("[pack-clean] Forbidden files in zip archive:");
266
+ for (const entry of badZipForbidden) {
267
+ console.error(` ${entry}`);
268
+ }
269
+ rmSync(zipArchivePath, { force: true });
270
+ process.exit(1);
271
+ }
272
+
273
+ const zipSizeKb = (statSync(zipArchivePath).size / 1024).toFixed(1);
274
+ console.log(`[pack-clean] zip Archive: ${zipArchivePath} (${zipSizeKb} KB, ${zipEntryNames.length} entries)`);
119
275
  console.log("[pack-clean] OK");
120
276
 
121
277
  function listFiles(dir) {