patchwarden 0.4.0 → 0.6.0

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 (139) 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/runner/agentInvocation.d.ts +23 -0
  24. package/dist/runner/agentInvocation.js +106 -0
  25. package/dist/runner/changeCapture.d.ts +29 -0
  26. package/dist/runner/changeCapture.js +107 -5
  27. package/dist/runner/runTask.js +70 -44
  28. package/dist/runner/simpleProcess.d.ts +21 -0
  29. package/dist/runner/simpleProcess.js +206 -0
  30. package/dist/runner/watch.js +17 -2
  31. package/dist/security/commandGuard.d.ts +2 -1
  32. package/dist/security/commandGuard.js +25 -3
  33. package/dist/security/riskEngine.d.ts +27 -0
  34. package/dist/security/riskEngine.js +118 -0
  35. package/dist/security/runtimeGuard.d.ts +6 -0
  36. package/dist/security/runtimeGuard.js +28 -0
  37. package/dist/smoke-test.js +1353 -7
  38. package/dist/tools/applyPatch.d.ts +16 -0
  39. package/dist/tools/applyPatch.js +41 -0
  40. package/dist/tools/auditSession.d.ts +5 -0
  41. package/dist/tools/auditSession.js +12 -0
  42. package/dist/tools/auditTask.d.ts +7 -0
  43. package/dist/tools/auditTask.js +94 -2
  44. package/dist/tools/createDirectSession.d.ts +14 -0
  45. package/dist/tools/createDirectSession.js +54 -0
  46. package/dist/tools/createTask.d.ts +48 -1
  47. package/dist/tools/createTask.js +298 -47
  48. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  49. package/dist/tools/finalizeDirectSession.js +84 -0
  50. package/dist/tools/getTaskSummary.d.ts +47 -0
  51. package/dist/tools/getTaskSummary.js +64 -1
  52. package/dist/tools/healthCheck.d.ts +22 -12
  53. package/dist/tools/healthCheck.js +23 -6
  54. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  55. package/dist/tools/readWorkspaceFile.js +48 -2
  56. package/dist/tools/registry.js +217 -19
  57. package/dist/tools/runVerification.d.ts +16 -0
  58. package/dist/tools/runVerification.js +32 -0
  59. package/dist/tools/savePlan.d.ts +1 -0
  60. package/dist/tools/savePlan.js +38 -7
  61. package/dist/tools/searchWorkspace.d.ts +19 -0
  62. package/dist/tools/searchWorkspace.js +205 -0
  63. package/dist/tools/taskTemplates.js +2 -1
  64. package/dist/tools/toolCatalog.d.ts +3 -2
  65. package/dist/tools/toolCatalog.js +32 -5
  66. package/dist/tools/waitForTask.d.ts +2 -2
  67. package/dist/tools/waitForTask.js +1 -1
  68. package/dist/version.d.ts +2 -2
  69. package/dist/version.js +2 -2
  70. package/docs/chatgpt-usage.md +101 -0
  71. package/docs/release-checklist.md +14 -0
  72. package/docs/release-v0.6.0.md +71 -0
  73. package/examples/config.example.json +5 -0
  74. package/examples/openai-tunnel/README.md +11 -4
  75. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  76. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  77. package/package.json +12 -8
  78. package/scripts/brand-check.js +58 -12
  79. package/scripts/control-smoke.js +206 -0
  80. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  81. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  82. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  83. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  84. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  85. package/scripts/lifecycle-smoke.js +66 -6
  86. package/scripts/manage-patchwarden.ps1 +639 -0
  87. package/scripts/mcp-manifest-check.js +100 -58
  88. package/scripts/mcp-smoke.js +157 -2
  89. package/scripts/pack-clean.js +3 -4
  90. package/scripts/package-manifest-check.js +78 -0
  91. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  92. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  93. package/scripts/restart-patchwarden.ps1 +5 -4
  94. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  95. package/scripts/tunnel-supervisor-smoke.js +36 -4
  96. package/scripts/watcher-supervisor-smoke.js +10 -6
  97. package/src/assessments/agentAssessor.ts +324 -0
  98. package/src/assessments/assessmentStore.ts +426 -0
  99. package/src/assessments/confirmCli.ts +29 -0
  100. package/src/config.ts +178 -4
  101. package/src/direct/directAudit.ts +400 -0
  102. package/src/direct/directGuards.ts +279 -0
  103. package/src/direct/directPatch.ts +258 -0
  104. package/src/direct/directSessionStore.ts +345 -0
  105. package/src/direct/directVerification.ts +138 -0
  106. package/src/doctor.ts +103 -7
  107. package/src/runner/agentInvocation.ts +125 -0
  108. package/src/runner/changeCapture.ts +140 -5
  109. package/src/runner/runTask.ts +61 -43
  110. package/src/runner/simpleProcess.ts +223 -0
  111. package/src/runner/watch.ts +18 -2
  112. package/src/security/commandGuard.ts +46 -4
  113. package/src/security/riskEngine.ts +160 -0
  114. package/src/security/runtimeGuard.ts +41 -0
  115. package/src/smoke-test.ts +1291 -4
  116. package/src/tools/applyPatch.ts +86 -0
  117. package/src/tools/auditSession.ts +28 -0
  118. package/src/tools/auditTask.ts +100 -2
  119. package/src/tools/createDirectSession.ts +113 -0
  120. package/src/tools/createTask.ts +405 -55
  121. package/src/tools/finalizeDirectSession.ts +144 -0
  122. package/src/tools/getTaskSummary.ts +111 -1
  123. package/src/tools/healthCheck.ts +23 -6
  124. package/src/tools/readWorkspaceFile.ts +85 -2
  125. package/src/tools/registry.ts +242 -19
  126. package/src/tools/runVerification.ts +58 -0
  127. package/src/tools/savePlan.ts +57 -7
  128. package/src/tools/searchWorkspace.ts +275 -0
  129. package/src/tools/taskTemplates.ts +2 -1
  130. package/src/tools/toolCatalog.ts +35 -6
  131. package/src/tools/waitForTask.ts +3 -3
  132. package/src/version.ts +2 -2
  133. package/tsconfig.json +18 -17
  134. package/Check-PatchWarden-Health.cmd +0 -6
  135. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  136. package/Restart-PatchWarden.cmd +0 -19
  137. package/Start-PatchWarden-Tunnel.cmd +0 -7
  138. package/docs/release-v0.3.0.md +0 -43
  139. package/docs/release-v0.4.0.md +0 -74
package/package.json CHANGED
@@ -1,25 +1,27 @@
1
1
  {
2
2
  "name": "patchwarden",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A secure local MCP bridge: clients save plans, local agents execute tasks, and results are returned through workspace-scoped files.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "bin": {
9
9
  "patchwarden": "dist/index.js",
10
+ "patchwarden-confirm": "dist/assessments/confirmCli.js",
10
11
  "patchwarden-runner": "dist/runner/cli.js"
11
12
  },
12
13
  "files": [
13
14
  "dist/",
14
15
  "docs/",
16
+ "!docs/optimization-proposal.md",
15
17
  "src/",
16
18
  "examples/",
17
19
  "scripts/",
18
- "Start-PatchWarden-Tunnel.cmd",
19
- "Restart-PatchWarden.cmd",
20
- "Check-PatchWarden-Health.cmd",
21
- "Reset-PatchWarden-Tunnel-Key.cmd",
20
+ "!scripts/kill-patchwarden.ps1",
21
+ "!scripts/launchers/Kill-PatchWarden.cmd",
22
+ "PatchWarden.cmd",
22
23
  "README.md",
24
+ "README.en.md",
23
25
  "LICENSE",
24
26
  "tsconfig.json"
25
27
  ],
@@ -31,25 +33,27 @@
31
33
  "runner": "node dist/runner/cli.js",
32
34
  "watch": "node dist/runner/watch.js",
33
35
  "doctor": "node dist/doctor.js",
34
- "test": "node dist/smoke-test.js && node scripts/lifecycle-smoke.js && node scripts/doctor-smoke.js && node scripts/tunnel-supervisor-smoke.js && node scripts/watcher-supervisor-smoke.js && node scripts/mcp-manifest-check.js && node scripts/brand-check.js",
36
+ "doctor:ci": "node dist/doctor.js --allow-default-config",
37
+ "test": "node dist/smoke-test.js && node scripts/lifecycle-smoke.js && node scripts/doctor-smoke.js && node scripts/tunnel-supervisor-smoke.js && node scripts/watcher-supervisor-smoke.js && node scripts/control-smoke.js && node scripts/mcp-manifest-check.js && node scripts/brand-check.js",
35
38
  "test:lifecycle": "npm run build && node scripts/lifecycle-smoke.js",
36
39
  "test:doctor": "npm run build && node scripts/doctor-smoke.js",
37
40
  "test:tunnel-supervisor": "npm run build && node scripts/tunnel-supervisor-smoke.js",
38
41
  "test:watcher-supervisor": "npm run build && node scripts/watcher-supervisor-smoke.js",
39
42
  "check:tool-manifest": "npm run build && node scripts/mcp-manifest-check.js",
43
+ "check:direct-tool-manifest": "npm run build && node scripts/mcp-manifest-check.js --profile chatgpt_direct",
40
44
  "check:brand": "node scripts/brand-check.js",
41
45
  "test:mcp": "npm run build && node scripts/mcp-smoke.js",
42
46
  "test:http-mcp": "npm run build && node scripts/http-mcp-smoke.js",
43
47
  "pretest": "npm run build",
44
48
  "pack:clean": "npm run build && node scripts/pack-clean.js",
45
- "verify:package": "npm run build && npm pack --dry-run"
49
+ "verify:package": "npm run build && node scripts/package-manifest-check.js"
46
50
  },
47
51
  "dependencies": {
48
52
  "@modelcontextprotocol/sdk": "^1.0.0"
49
53
  },
50
54
  "devDependencies": {
51
55
  "@types/node": "^20.0.0",
52
- "typescript": "^5.0.0"
56
+ "typescript": "^6.0.3"
53
57
  },
54
58
  "engines": {
55
59
  "node": ">=18.0.0"
@@ -1,25 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from "node:child_process";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { join, relative } from "node:path";
4
5
 
5
6
  const allowedLegacyFiles = new Set([
6
7
  ".gitignore",
7
8
  ".npmignore",
8
9
  "README.md",
10
+ "README.en.md",
9
11
  "docs/migration-from-safe-bifrost.md",
10
12
  "docs/release-v0.3.0.md",
11
13
  "docs/release-v0.4.0.md",
14
+ "docs/release-v0.6.0.md",
12
15
  "scripts/brand-check.js",
13
16
  "scripts/pack-clean.js",
14
17
  ]);
15
18
  const legacyPattern = /safe-bifrost|Safe-Bifrost|SAFE_BIFROST|SafeBifrost|safe_bifrost/;
16
- const trackedFiles = execFileSync(
17
- "git",
18
- ["ls-files", "--cached", "--others", "--exclude-standard"],
19
- { encoding: "utf-8" }
20
- )
21
- .split(/\r?\n/)
22
- .filter(Boolean);
19
+
20
+ const EXCLUDE_DIRS = new Set(["node_modules", ".npm-cache", "dist", "release", ".patchwarden", ".git", "logs", "tmp", "coverage", "build", "out", ".next"]);
21
+
22
+ let trackedFiles;
23
+ let inGit = false;
24
+ try {
25
+ trackedFiles = execFileSync(
26
+ "git",
27
+ ["ls-files", "--cached", "--others", "--exclude-standard"],
28
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
29
+ )
30
+ .split(/\r?\n/)
31
+ .filter(Boolean);
32
+ inGit = true;
33
+ } catch {
34
+ // Not a git repository — fall back to filesystem walk
35
+ console.warn("[brand-check] Not a Git repository; using filesystem walk.");
36
+ trackedFiles = walkFiles(".");
37
+ if (trackedFiles.length === 0) {
38
+ console.warn("[brand-check] WARNING: no files found in non-Git walk. Exiting cleanly.");
39
+ process.exit(0);
40
+ }
41
+ }
42
+
23
43
  const failures = [];
24
44
 
25
45
  for (const file of trackedFiles) {
@@ -29,9 +49,13 @@ for (const file of trackedFiles) {
29
49
  failures.push(`${normalized}: legacy brand in path`);
30
50
  continue;
31
51
  }
32
- const content = readFileSync(file);
33
- if (!content.includes(0) && legacyPattern.test(content.toString("utf-8"))) {
34
- failures.push(`${normalized}: legacy brand in content`);
52
+ try {
53
+ const content = readFileSync(file);
54
+ if (!content.includes(0) && legacyPattern.test(content.toString("utf-8"))) {
55
+ failures.push(`${normalized}: legacy brand in content`);
56
+ }
57
+ } catch {
58
+ // skip unreadable files
35
59
  }
36
60
  }
37
61
 
@@ -41,4 +65,26 @@ if (failures.length > 0) {
41
65
  process.exit(1);
42
66
  }
43
67
 
44
- console.log(`[brand-check] OK: ${trackedFiles.length} tracked files checked.`);
68
+ const label = inGit ? "tracked files checked" : "files scanned (non-Git fallback)";
69
+ console.log(`[brand-check] OK: ${trackedFiles.length} ${label}.`);
70
+
71
+ function walkFiles(root) {
72
+ const results = [];
73
+ const visit = (dir) => {
74
+ if (results.length > 10000) return;
75
+ let entries;
76
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
77
+ for (const e of entries) {
78
+ if (results.length > 10000) break;
79
+ if (e.isDirectory()) {
80
+ if (EXCLUDE_DIRS.has(e.name)) continue;
81
+ visit(join(dir, e.name));
82
+ } else if (e.isFile()) {
83
+ const rel = relative(root, join(dir, e.name)).replace(/\\/g, "/");
84
+ results.push(rel);
85
+ }
86
+ }
87
+ };
88
+ visit(root);
89
+ return results;
90
+ }
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { join, resolve } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ if (process.platform !== "win32") {
9
+ console.log("ok - PatchWarden control smoke skipped outside Windows");
10
+ process.exit(0);
11
+ }
12
+
13
+ const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
14
+ const root = resolve(scriptDir, "..");
15
+ const manager = join(scriptDir, "manage-patchwarden.ps1");
16
+ const temp = mkdtempSync(join(tmpdir(), "patchwarden-control-smoke-"));
17
+ const mockConfig = join(temp, "patchwarden.config.json");
18
+ writeFileSync(mockConfig, JSON.stringify({
19
+ workspaceRoot: temp,
20
+ plansDir: ".patchwarden/plans",
21
+ tasksDir: ".patchwarden/tasks",
22
+ }), "utf8");
23
+ const env = {
24
+ ...process.env,
25
+ LOCALAPPDATA: join(temp, "LocalAppData"),
26
+ APPDATA: join(temp, "AppData"),
27
+ TEMP: join(temp, "Temp"),
28
+ TMP: join(temp, "Temp"),
29
+ PATCHWARDEN_CONFIG: mockConfig,
30
+ };
31
+ let fakeTunnel = null;
32
+ let fakeWatcher = null;
33
+ let healthServer = null;
34
+
35
+ try {
36
+ const statusOutput = run(["status", "all", "-Json"]);
37
+ const statuses = JSON.parse(statusOutput);
38
+ if (!Array.isArray(statuses) || statuses.length !== 2) {
39
+ throw new Error(`expected two status rows, got: ${statusOutput}`);
40
+ }
41
+ const byMode = new Map(statuses.map((entry) => [entry.mode, entry]));
42
+ if (byMode.get("core")?.tool_profile !== "chatgpt_core") {
43
+ throw new Error("Core status did not report chatgpt_core");
44
+ }
45
+ if (byMode.get("direct")?.tool_profile !== "chatgpt_direct") {
46
+ throw new Error("Direct status did not report chatgpt_direct");
47
+ }
48
+
49
+ const startPlan = run(["start", "all", "-WhatIf"]);
50
+ requireText(startPlan, "start:core");
51
+ requireText(startPlan, "start:direct");
52
+
53
+ const restartPlan = run(["restart", "direct", "-WhatIf", "-SkipBuild"]);
54
+ requireText(restartPlan, "stop:direct");
55
+ requireText(restartPlan, "start:direct");
56
+
57
+ const fakeDirectory = join(temp, "fake-tunnel");
58
+ const fakeExecutable = join(fakeDirectory, "tunnel-client.exe");
59
+ const fakeScript = join(fakeDirectory, "sleep.ps1");
60
+ const systemPowerShell = join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
61
+ mkdirSync(fakeDirectory, { recursive: true });
62
+ copyFileSync(systemPowerShell, fakeExecutable);
63
+ writeFileSync(fakeScript, "Start-Sleep -Seconds 120\r\n", "utf8");
64
+ fakeTunnel = spawn(
65
+ fakeExecutable,
66
+ ["-NoProfile", "-File", fakeScript, "run", "--profile", "patchwarden-direct"],
67
+ { stdio: "ignore", windowsHide: true }
68
+ );
69
+ await delay(500);
70
+ if (fakeTunnel.exitCode !== null) throw new Error("fake Tunnel process exited before stop test");
71
+ const directRuntime = join(env.LOCALAPPDATA, "patchwarden", "runtime-direct");
72
+ mkdirSync(directRuntime, { recursive: true });
73
+ mkdirSync(env.TEMP, { recursive: true });
74
+ writeFileSync(join(directRuntime, "tunnel-status.json"), JSON.stringify({
75
+ status: "stopped",
76
+ ready: false,
77
+ pid: null,
78
+ tool_profile: "chatgpt_direct",
79
+ tool_count: 9,
80
+ tools_ready: true,
81
+ }), "utf8");
82
+ writeFileSync(join(directRuntime, "tunnel-client.pid"), String(fakeTunnel.pid), "utf8");
83
+ writeFileSync(join(directRuntime, "tunnel-health-url.txt"), "http://127.0.0.1:8081", "utf8");
84
+ const legacyDirectPid = join(env.TEMP, "patchwarden-direct.pid");
85
+ const legacyDirectUrl = join(env.TEMP, "patchwarden-direct-health.url");
86
+ writeFileSync(legacyDirectPid, String(fakeTunnel.pid), "utf8");
87
+ writeFileSync(legacyDirectUrl, "http://127.0.0.1:8081", "utf8");
88
+ const stopOutput = run(["stop", "direct"]);
89
+ requireText(stopOutput, `Stopped PID ${fakeTunnel.pid}`);
90
+ await waitForExit(fakeTunnel, 5000);
91
+ if (fakeTunnel.exitCode === null) throw new Error("manager did not stop the owned Direct fixture process");
92
+ for (const stalePath of [join(directRuntime, "tunnel-client.pid"), join(directRuntime, "tunnel-health-url.txt"), legacyDirectPid, legacyDirectUrl]) {
93
+ if (existsSync(stalePath)) throw new Error(`manager did not clean stale runtime file: ${stalePath}`);
94
+ }
95
+
96
+ fakeWatcher = spawn(
97
+ process.execPath,
98
+ ["-e", "setTimeout(()=>{},120000)", join(root, "dist", "runner", "watch.js")],
99
+ { stdio: "ignore", windowsHide: true }
100
+ );
101
+ await delay(500);
102
+ if (fakeWatcher.exitCode !== null) throw new Error("fake watcher exited before kill test");
103
+ const killOutput = run(["kill", "core"]);
104
+ requireText(killOutput, `Stopped PID ${fakeWatcher.pid}`);
105
+ await waitForExit(fakeWatcher, 5000);
106
+ if (fakeWatcher.exitCode === null) throw new Error("kill core did not stop the project-scoped watcher fixture");
107
+
108
+ const coreRuntime = join(env.LOCALAPPDATA, "patchwarden", "runtime");
109
+ mkdirSync(coreRuntime, { recursive: true });
110
+ writeFileSync(join(coreRuntime, "tunnel-status.json"), JSON.stringify({
111
+ status: "stopped",
112
+ ready: false,
113
+ pid: null,
114
+ reason_code: "stale_fixture",
115
+ last_error: "stale failure",
116
+ tool_profile: "chatgpt_core",
117
+ tool_count: 16,
118
+ tools_ready: true,
119
+ }), "utf8");
120
+ healthServer = spawn(
121
+ process.execPath,
122
+ ["-e", "require('http').createServer((req,res)=>{res.writeHead(200,{'content-type':'application/json'});res.end(JSON.stringify({ok:true}))}).listen(8080,'127.0.0.1')"],
123
+ { stdio: "ignore", windowsHide: true }
124
+ );
125
+ await delay(750);
126
+ if (healthServer.exitCode !== null) throw new Error("health fallback fixture could not listen on 127.0.0.1:8080");
127
+ const coreStatusRaw = run(["status", "core", "-Json"]);
128
+ const coreStatusValue = JSON.parse(coreStatusRaw);
129
+ const coreStatus = Array.isArray(coreStatusValue) ? coreStatusValue[0] : coreStatusValue;
130
+ if (coreStatus.status !== "running" || coreStatus.ready !== true || coreStatus.health_alive !== true || coreStatus.reason_code !== "health_endpoint_ready") {
131
+ throw new Error(`health fallback did not override stale runtime JSON: ${coreStatusRaw}`);
132
+ }
133
+ const conflict = runFailure(["restart", "core", "-WhatIf", "-SkipBuild"]);
134
+ requireText(`${conflict.stdout}\n${conflict.stderr}`, "Unsafe health-port conflict");
135
+ if (healthServer.exitCode !== null) throw new Error("manager killed an unrelated health-port owner");
136
+ const scopedKill = run(["kill", "core"]);
137
+ requireText(scopedKill, "No matching Core Agent process");
138
+ if (healthServer.exitCode !== null) throw new Error("kill core terminated an unrelated process");
139
+
140
+ const expectedFiles = [
141
+ "PatchWarden.cmd",
142
+ "scripts/manage-patchwarden.ps1",
143
+ "scripts/launchers/Start-PatchWarden-Tunnel.cmd",
144
+ "scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd",
145
+ ];
146
+ for (const relativePath of expectedFiles) {
147
+ if (!existsSync(join(root, relativePath))) {
148
+ throw new Error(`missing consolidated control file: ${relativePath}`);
149
+ }
150
+ }
151
+ const rootEntry = readFileSync(join(root, "PatchWarden.cmd"), "utf8");
152
+ if (!rootEntry.includes("manage-patchwarden.ps1")) {
153
+ throw new Error("PatchWarden.cmd does not invoke the consolidated manager");
154
+ }
155
+ console.log("ok - control handles orphan cleanup, scoped kill, port conflicts, health fallback, and Core/Direct lifecycle actions");
156
+ } finally {
157
+ if (fakeTunnel?.exitCode === null) fakeTunnel.kill();
158
+ if (fakeWatcher?.exitCode === null) fakeWatcher.kill();
159
+ if (healthServer?.exitCode === null) healthServer.kill();
160
+ rmSync(temp, { recursive: true, force: true });
161
+ }
162
+
163
+ function run(args) {
164
+ const result = spawnSync(
165
+ "powershell.exe",
166
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", manager, ...args],
167
+ { cwd: root, env, encoding: "utf8" }
168
+ );
169
+ if (result.status !== 0) {
170
+ throw new Error(`manager failed (${args.join(" ")}):\n${result.stdout}\n${result.stderr}`);
171
+ }
172
+ return result.stdout.trim();
173
+ }
174
+
175
+ function runFailure(args) {
176
+ const result = spawnSync(
177
+ "powershell.exe",
178
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", manager, ...args],
179
+ { cwd: root, env, encoding: "utf8" }
180
+ );
181
+ if (result.status === 0) {
182
+ throw new Error(`manager unexpectedly succeeded (${args.join(" ")}):\n${result.stdout}\n${result.stderr}`);
183
+ }
184
+ return result;
185
+ }
186
+
187
+ function requireText(value, expected) {
188
+ if (!value.toLowerCase().includes(expected.toLowerCase())) {
189
+ throw new Error(`expected ${JSON.stringify(expected)} in output:\n${value}`);
190
+ }
191
+ }
192
+
193
+ function delay(milliseconds) {
194
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
195
+ }
196
+
197
+ function waitForExit(child, timeoutMilliseconds) {
198
+ if (child.exitCode !== null) return Promise.resolve();
199
+ return new Promise((resolvePromise, rejectPromise) => {
200
+ const timer = setTimeout(() => rejectPromise(new Error("timed out waiting for fixture process to exit")), timeoutMilliseconds);
201
+ child.once("exit", () => {
202
+ clearTimeout(timer);
203
+ resolvePromise();
204
+ });
205
+ });
206
+ }
@@ -0,0 +1,6 @@
1
+ @echo off
2
+ setlocal
3
+ cd /d "%~dp0..\.."
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\manage-patchwarden.ps1" status all
5
+ echo.
6
+ pause
@@ -0,0 +1,6 @@
1
+ @echo off
2
+ setlocal
3
+ cd /d "%~dp0..\.."
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\start-patchwarden-tunnel.ps1" -ForgetSavedApiKey
5
+ echo.
6
+ pause
@@ -0,0 +1,6 @@
1
+ @echo off
2
+ setlocal
3
+ cd /d "%~dp0..\.."
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\manage-patchwarden.ps1" restart core
5
+ echo.
6
+ pause
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ cd /d "%~dp0..\.."
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\start-patchwarden-tunnel.ps1" -ToolProfile chatgpt_direct -Profile patchwarden-direct -HealthListenAddr 127.0.0.1:8081 -SkipWatcher
5
+ echo.
6
+ echo PatchWarden Direct tunnel launcher exited.
7
+ pause
@@ -0,0 +1,7 @@
1
+ @echo off
2
+ setlocal
3
+ cd /d "%~dp0..\.."
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0..\start-patchwarden-tunnel.ps1" -ToolProfile chatgpt_core -Profile patchwarden -HealthListenAddr 127.0.0.1:8080
5
+ echo.
6
+ echo PatchWarden Core tunnel launcher exited.
7
+ pause
@@ -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,13 @@ 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");
138
162
 
139
163
  await test("list_agents reports configured executables", async () => {
140
164
  const result = listAgents();
141
- if (result.total !== 7 || result.agents.some((agent) => !agent.available)) {
165
+ if (result.total !== 8 || result.agents.some((agent) => !agent.available)) {
142
166
  throw new Error(`Unexpected agent availability: ${JSON.stringify(result)}`);
143
167
  }
144
168
  });
@@ -295,6 +319,35 @@ try {
295
319
  }
296
320
  });
297
321
 
322
+ await test("artifact hygiene separates source, ignored output, runtime data, and suspicious changes", async () => {
323
+ const plan = savePlan({ title: "Artifact hygiene", content: "Generate representative task outputs." });
324
+ const task = createTask({ plan_id: plan.plan_id, agent: "artifactwriter", repo_path: "repo" });
325
+ const result = await runTask(task.task_id);
326
+ if (result.status !== "done") throw new Error(`Artifact task failed: ${JSON.stringify(result)}`);
327
+ const standard = getTaskSummary(task.task_id);
328
+ const compact = getTaskSummary(task.task_id, { view: "compact", max_items: 1 });
329
+ const counts = standard.artifact_hygiene?.counts || {};
330
+ if (
331
+ counts.tracked_build_artifacts < 1 ||
332
+ counts.ignored_untracked_artifacts < 2 ||
333
+ counts.runtime_generated_files < 1 ||
334
+ counts.suspicious_changes < 2
335
+ ) {
336
+ throw new Error(`Artifact classification mismatch: ${JSON.stringify(standard.artifact_hygiene)}`);
337
+ }
338
+ if (compact.view !== "compact" || "log_tails" in compact || compact.artifact_hygiene.max_items !== 1) {
339
+ throw new Error(`Compact summary leaked standard detail: ${JSON.stringify(compact)}`);
340
+ }
341
+ if (!Array.isArray(standard.changed_files) || standard.changed_files.length < 4) {
342
+ throw new Error("Standard summary no longer preserves full changed-file evidence");
343
+ }
344
+ const audit = auditTask(task.task_id);
345
+ const hygieneCheck = audit.checks.find((check) => check.name === "artifact_hygiene");
346
+ if (!hygieneCheck || hygieneCheck.result !== "warn") {
347
+ throw new Error(`Audit did not surface suspicious artifact evidence: ${JSON.stringify(audit)}`);
348
+ }
349
+ });
350
+
298
351
  await test("deleted tracked files are identified with file stats", async () => {
299
352
  const plan = savePlan({ title: "Delete fixture", content: "Delete the designated fixture file." });
300
353
  const task = createTask({ plan_id: plan.plan_id, agent: "deleter", repo_path: "repo" });
@@ -311,10 +364,13 @@ try {
311
364
  const task = createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "repo" });
312
365
  const running = runTask(task.task_id);
313
366
  const waited = await waitForTask(task.task_id, 5);
314
- await running;
367
+ await raceWithTimeout(running, 15000, "wait_for_task loop did not terminate within 15s");
315
368
  if (!waited.terminal || waited.continuation_required || !waited.summary) {
316
369
  throw new Error(`Unexpected wait response: ${JSON.stringify(waited)}`);
317
370
  }
371
+ if (waited.summary.view !== "compact" || "log_tails" in waited.summary) {
372
+ throw new Error(`Terminal wait should embed compact evidence: ${JSON.stringify(waited.summary)}`);
373
+ }
318
374
  });
319
375
 
320
376
  await test("wait_for_task explicitly requires another call when the task is not terminal", async () => {
@@ -338,7 +394,7 @@ try {
338
394
  throw new Error(`Running summary incomplete: ${JSON.stringify(summary)}`);
339
395
  }
340
396
  cancelTask(task.task_id);
341
- await running;
397
+ await raceWithTimeout(running, 15000, "running summary cancel did not terminate within 15s");
342
398
  });
343
399
 
344
400
  await test("verification failure produces failed_verification and structured evidence", async () => {
@@ -423,7 +479,7 @@ try {
423
479
  if (!request.cancel_requested || request.force_kill_requested) {
424
480
  throw new Error(`Unexpected cancel response: ${JSON.stringify(request)}`);
425
481
  }
426
- const result = await running;
482
+ const result = await raceWithTimeout(running, 15000, "cancel_task did not terminate within 15s");
427
483
  if (result.status !== "canceled") throw new Error(`Expected canceled, got ${JSON.stringify(result)}`);
428
484
  });
429
485
 
@@ -434,7 +490,7 @@ try {
434
490
  await waitForRunning(getTaskStatus, task.task_id);
435
491
  const request = killTask(task.task_id);
436
492
  if (!request.force_kill_requested) throw new Error(`Unexpected kill response: ${JSON.stringify(request)}`);
437
- const result = await running;
493
+ const result = await raceWithTimeout(running, 15000, "kill_task did not terminate within 15s");
438
494
  if (result.status !== "canceled" || !result.error?.includes("kill_task")) {
439
495
  throw new Error(`Expected killed/canceled result, got ${JSON.stringify(result)}`);
440
496
  }
@@ -451,3 +507,7 @@ console.log(`${"=".repeat(50)}\n`);
451
507
 
452
508
  if (failed > 0) process.exit(1);
453
509
  console.log("ALL LIFECYCLE TESTS PASSED\n");
510
+
511
+ // Safety: force exit after cleanup in case any stray timer/handle keeps the event loop alive.
512
+ // All child processes are killed by the runner; this only guards against OS-level pipe drains.
513
+ process.exit(0);