patchwarden 0.4.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 (153) hide show
  1. package/Check-PatchWarden-Health.cmd +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +526 -0
  4. package/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  5. package/Restart-PatchWarden.cmd +19 -0
  6. package/Start-PatchWarden-Tunnel.cmd +7 -0
  7. package/dist/config.d.ts +24 -0
  8. package/dist/config.js +131 -0
  9. package/dist/doctor.d.ts +10 -0
  10. package/dist/doctor.js +388 -0
  11. package/dist/errors.d.ts +16 -0
  12. package/dist/errors.js +27 -0
  13. package/dist/httpServer.d.ts +17 -0
  14. package/dist/httpServer.js +255 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +24 -0
  17. package/dist/runner/changeCapture.d.ts +43 -0
  18. package/dist/runner/changeCapture.js +245 -0
  19. package/dist/runner/cli.d.ts +9 -0
  20. package/dist/runner/cli.js +26 -0
  21. package/dist/runner/runTask.d.ts +8 -0
  22. package/dist/runner/runTask.js +821 -0
  23. package/dist/runner/watch.d.ts +19 -0
  24. package/dist/runner/watch.js +136 -0
  25. package/dist/security/commandGuard.d.ts +22 -0
  26. package/dist/security/commandGuard.js +56 -0
  27. package/dist/security/contentRedaction.d.ts +12 -0
  28. package/dist/security/contentRedaction.js +65 -0
  29. package/dist/security/pathGuard.d.ts +3 -0
  30. package/dist/security/pathGuard.js +98 -0
  31. package/dist/security/planGuard.d.ts +1 -0
  32. package/dist/security/planGuard.js +65 -0
  33. package/dist/security/sensitiveGuard.d.ts +2 -0
  34. package/dist/security/sensitiveGuard.js +60 -0
  35. package/dist/smoke-test.d.ts +15 -0
  36. package/dist/smoke-test.js +989 -0
  37. package/dist/taskProgress.d.ts +6 -0
  38. package/dist/taskProgress.js +36 -0
  39. package/dist/taskRuntime.d.ts +10 -0
  40. package/dist/taskRuntime.js +25 -0
  41. package/dist/tools/auditTask.d.ts +19 -0
  42. package/dist/tools/auditTask.js +307 -0
  43. package/dist/tools/cancelTask.d.ts +30 -0
  44. package/dist/tools/cancelTask.js +64 -0
  45. package/dist/tools/createTask.d.ts +42 -0
  46. package/dist/tools/createTask.js +188 -0
  47. package/dist/tools/getPlan.d.ts +12 -0
  48. package/dist/tools/getPlan.js +29 -0
  49. package/dist/tools/getTaskFile.d.ts +24 -0
  50. package/dist/tools/getTaskFile.js +84 -0
  51. package/dist/tools/getTaskProgress.d.ts +1 -0
  52. package/dist/tools/getTaskProgress.js +4 -0
  53. package/dist/tools/getTaskStatus.d.ts +46 -0
  54. package/dist/tools/getTaskStatus.js +36 -0
  55. package/dist/tools/getTaskStdoutTail.d.ts +9 -0
  56. package/dist/tools/getTaskStdoutTail.js +56 -0
  57. package/dist/tools/getTaskSummary.d.ts +66 -0
  58. package/dist/tools/getTaskSummary.js +237 -0
  59. package/dist/tools/healthCheck.d.ts +79 -0
  60. package/dist/tools/healthCheck.js +230 -0
  61. package/dist/tools/killTask.d.ts +15 -0
  62. package/dist/tools/killTask.js +4 -0
  63. package/dist/tools/listAgents.d.ts +12 -0
  64. package/dist/tools/listAgents.js +42 -0
  65. package/dist/tools/listTasks.d.ts +36 -0
  66. package/dist/tools/listTasks.js +94 -0
  67. package/dist/tools/listWorkspace.d.ts +10 -0
  68. package/dist/tools/listWorkspace.js +54 -0
  69. package/dist/tools/readWorkspaceFile.d.ts +8 -0
  70. package/dist/tools/readWorkspaceFile.js +26 -0
  71. package/dist/tools/registry.d.ts +24 -0
  72. package/dist/tools/registry.js +566 -0
  73. package/dist/tools/retryTask.d.ts +8 -0
  74. package/dist/tools/retryTask.js +45 -0
  75. package/dist/tools/savePlan.d.ts +10 -0
  76. package/dist/tools/savePlan.js +28 -0
  77. package/dist/tools/taskOutputs.d.ts +49 -0
  78. package/dist/tools/taskOutputs.js +136 -0
  79. package/dist/tools/taskTemplates.d.ts +15 -0
  80. package/dist/tools/taskTemplates.js +116 -0
  81. package/dist/tools/toolCatalog.d.ts +19 -0
  82. package/dist/tools/toolCatalog.js +71 -0
  83. package/dist/tools/waitForTask.d.ts +25 -0
  84. package/dist/tools/waitForTask.js +59 -0
  85. package/dist/version.d.ts +2 -0
  86. package/dist/version.js +2 -0
  87. package/dist/watcherStatus.d.ts +20 -0
  88. package/dist/watcherStatus.js +77 -0
  89. package/docs/assets/patchwarden-chatgpt-demo.svg +30 -0
  90. package/docs/demo.md +58 -0
  91. package/docs/migration-from-safe-bifrost.md +31 -0
  92. package/docs/release-v0.3.0.md +43 -0
  93. package/docs/release-v0.4.0.md +74 -0
  94. package/examples/config.example.json +40 -0
  95. package/examples/openai-tunnel/README.md +48 -0
  96. package/examples/openai-tunnel/chatgpt-test-prompt.md +53 -0
  97. package/examples/openai-tunnel/tunnel-client.example.yaml +39 -0
  98. package/package.json +73 -0
  99. package/scripts/brand-check.js +44 -0
  100. package/scripts/doctor-smoke.js +52 -0
  101. package/scripts/get-patchwarden-health.ps1 +114 -0
  102. package/scripts/http-mcp-smoke.js +351 -0
  103. package/scripts/lifecycle-smoke.js +453 -0
  104. package/scripts/mcp-manifest-check.js +120 -0
  105. package/scripts/mcp-smoke.js +336 -0
  106. package/scripts/pack-clean.js +142 -0
  107. package/scripts/patchwarden-mcp-stdio.cmd +7 -0
  108. package/scripts/restart-patchwarden.ps1 +242 -0
  109. package/scripts/start-patchwarden-tunnel.ps1 +503 -0
  110. package/scripts/tunnel-supervisor-smoke.js +77 -0
  111. package/scripts/watcher-supervisor-smoke.js +195 -0
  112. package/src/config.ts +165 -0
  113. package/src/doctor.ts +438 -0
  114. package/src/errors.ts +26 -0
  115. package/src/httpServer.ts +277 -0
  116. package/src/index.ts +34 -0
  117. package/src/runner/changeCapture.ts +304 -0
  118. package/src/runner/cli.ts +34 -0
  119. package/src/runner/runTask.ts +935 -0
  120. package/src/runner/watch.ts +152 -0
  121. package/src/security/commandGuard.ts +97 -0
  122. package/src/security/contentRedaction.ts +85 -0
  123. package/src/security/pathGuard.ts +175 -0
  124. package/src/security/planGuard.ts +93 -0
  125. package/src/security/sensitiveGuard.ts +72 -0
  126. package/src/smoke-test.ts +1078 -0
  127. package/src/taskProgress.ts +41 -0
  128. package/src/taskRuntime.ts +37 -0
  129. package/src/tools/auditTask.ts +340 -0
  130. package/src/tools/cancelTask.ts +70 -0
  131. package/src/tools/createTask.ts +337 -0
  132. package/src/tools/getPlan.ts +48 -0
  133. package/src/tools/getTaskFile.ts +111 -0
  134. package/src/tools/getTaskProgress.ts +5 -0
  135. package/src/tools/getTaskStatus.ts +85 -0
  136. package/src/tools/getTaskStdoutTail.ts +61 -0
  137. package/src/tools/getTaskSummary.ts +307 -0
  138. package/src/tools/healthCheck.ts +234 -0
  139. package/src/tools/killTask.ts +5 -0
  140. package/src/tools/listAgents.ts +54 -0
  141. package/src/tools/listTasks.ts +139 -0
  142. package/src/tools/listWorkspace.ts +71 -0
  143. package/src/tools/readWorkspaceFile.ts +41 -0
  144. package/src/tools/registry.ts +666 -0
  145. package/src/tools/retryTask.ts +53 -0
  146. package/src/tools/savePlan.ts +46 -0
  147. package/src/tools/taskOutputs.ts +155 -0
  148. package/src/tools/taskTemplates.ts +162 -0
  149. package/src/tools/toolCatalog.ts +92 -0
  150. package/src/tools/waitForTask.ts +90 -0
  151. package/src/version.ts +2 -0
  152. package/src/watcherStatus.ts +102 -0
  153. package/tsconfig.json +17 -0
@@ -0,0 +1,821 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { createWriteStream, existsSync, readFileSync, writeFileSync, } from "node:fs";
3
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { getTasksDir, getPlansDir, getConfig, resolveWorkspaceRoot } from "../config.js";
5
+ import { guardPath, guardWorkspacePath } from "../security/pathGuard.js";
6
+ import { guardAgentCommand, guardTestCommand, sanitizePromptArg, } from "../security/commandGuard.js";
7
+ import { writeTaskProgress } from "../taskProgress.js";
8
+ import { writeTaskRuntime } from "../taskRuntime.js";
9
+ import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, writeSnapshot, } from "./changeCapture.js";
10
+ const HEARTBEAT_INTERVAL_MS = 2000;
11
+ const GRACEFUL_KILL_MS = 2000;
12
+ const MAX_CAPTURE_CHARS = 100_000;
13
+ export async function runTask(taskId) {
14
+ const config = getConfig();
15
+ const tasksDir = getTasksDir(config);
16
+ const plansDir = getPlansDir(config);
17
+ const wsRoot = resolveWorkspaceRoot(config);
18
+ const taskDir = resolve(tasksDir, taskId);
19
+ guardPath(taskDir, wsRoot, config.tasksDir);
20
+ const statusFile = join(taskDir, "status.json");
21
+ if (!existsSync(statusFile))
22
+ throw new Error(`Task not found: "${taskId}"`);
23
+ const initialStatus = readStatus(statusFile);
24
+ const planId = String(initialStatus.plan_id || "");
25
+ const agentName = String(initialStatus.agent || "");
26
+ const rawRepoPath = String(initialStatus.resolved_repo_path || initialStatus.repo_path || wsRoot);
27
+ const testCommand = String(initialStatus.test_command || "");
28
+ const changePolicy = String(initialStatus.change_policy || "repo_scoped_changes");
29
+ let verifyCommands;
30
+ let timeoutSeconds;
31
+ try {
32
+ verifyCommands = normalizeVerifyCommands(initialStatus.verify_commands, testCommand, config);
33
+ timeoutSeconds = normalizeTimeout(initialStatus.timeout_seconds, config);
34
+ }
35
+ catch (error) {
36
+ return failBeforeExecution(taskId, taskDir, errorMessage(error));
37
+ }
38
+ const startedAtMs = Date.now();
39
+ const deadlineMs = startedAtMs + timeoutSeconds * 1000;
40
+ let repoPath;
41
+ try {
42
+ repoPath = guardWorkspacePath(rawRepoPath, wsRoot);
43
+ }
44
+ catch (error) {
45
+ const message = `repo_path validation failed: ${errorMessage(error)}`;
46
+ return failBeforeExecution(taskId, taskDir, message);
47
+ }
48
+ updateStatus(taskDir, {
49
+ status: "running",
50
+ phase: "preparing",
51
+ started_at: new Date(startedAtMs).toISOString(),
52
+ last_heartbeat_at: new Date().toISOString(),
53
+ timeout_seconds: timeoutSeconds,
54
+ current_command: null,
55
+ error: null,
56
+ });
57
+ setTaskPhase(taskDir, "preparing", null, "Capturing pre-task repository state.");
58
+ let beforeSnapshot;
59
+ let beforeWorkspaceSnapshot;
60
+ try {
61
+ beforeSnapshot = captureRepoSnapshot(repoPath);
62
+ beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : captureRepoSnapshot(wsRoot);
63
+ writeSnapshot(taskDir, "git-before.json", beforeSnapshot);
64
+ writeSnapshot(taskDir, "workspace-before.json", beforeWorkspaceSnapshot);
65
+ }
66
+ catch (error) {
67
+ const message = `Pre-task snapshot failed: ${errorMessage(error)}`;
68
+ return failBeforeExecution(taskId, taskDir, message);
69
+ }
70
+ let agentResult = null;
71
+ let testResult = skippedTest(testCommand, repoPath, "Agent did not complete successfully.");
72
+ const verifyResults = [];
73
+ let finalStatus = "failed";
74
+ let finalError = null;
75
+ try {
76
+ const planFile = resolve(plansDir, planId, "plan.md");
77
+ if (!existsSync(planFile))
78
+ throw new Error(`Plan not found: "${planId}". Save the plan first.`);
79
+ const planContent = readFileSync(planFile, "utf-8");
80
+ const agentCmd = guardAgentCommand(agentName, config);
81
+ const prompt = sanitizePromptArg(buildExecutionPrompt(planContent, repoPath, testCommand));
82
+ const resolvedArgs = agentCmd.args.map((arg) => {
83
+ if (arg === "{repo}")
84
+ return repoPath;
85
+ if (arg === "{prompt}")
86
+ return prompt;
87
+ return arg;
88
+ });
89
+ const agentCommandLabel = `${basename(agentCmd.command)} (configured agent command)`;
90
+ setTaskPhase(taskDir, "executing_agent", agentCommandLabel);
91
+ agentResult = await runManagedProcess({
92
+ command: agentCmd.command,
93
+ args: resolvedArgs,
94
+ cwd: repoPath,
95
+ taskDir,
96
+ statusFile,
97
+ phase: "executing_agent",
98
+ currentCommand: agentCommandLabel,
99
+ deadlineMs,
100
+ stdoutPath: join(taskDir, "stdout.log"),
101
+ stderrPath: join(taskDir, "stderr.log"),
102
+ });
103
+ if (agentResult.terminationReason === "canceled" || agentResult.terminationReason === "killed") {
104
+ finalStatus = "canceled";
105
+ finalError = agentResult.terminationReason === "killed"
106
+ ? "Task was terminated by kill_task."
107
+ : "Task was canceled by user request.";
108
+ }
109
+ else if (agentResult.terminationReason === "timeout") {
110
+ finalError = `Task timed out after ${timeoutSeconds} seconds during agent execution.`;
111
+ }
112
+ else if (agentResult.spawnError) {
113
+ finalError = `Agent spawn failed: ${agentResult.spawnError}`;
114
+ }
115
+ else if (agentResult.exitCode !== 0) {
116
+ finalError = `Agent exited with code ${agentResult.exitCode}.`;
117
+ }
118
+ else if (verifyCommands.length > 0) {
119
+ for (const command of verifyCommands) {
120
+ setTaskPhase(taskDir, "running_tests", command);
121
+ const verification = await runTrustedTestCommand(command, repoPath, taskDir, statusFile, deadlineMs);
122
+ verifyResults.push(verification);
123
+ if (verification.terminationReason || Date.now() >= deadlineMs)
124
+ break;
125
+ }
126
+ testResult = verifyResults[0] || skippedTest(testCommand, repoPath, "No verification command ran.");
127
+ const interrupted = verifyResults.find((result) => result.terminationReason);
128
+ const failedVerification = verifyResults.find((result) => result.spawnError || result.exitCode !== 0);
129
+ if (interrupted?.terminationReason === "canceled" || interrupted?.terminationReason === "killed") {
130
+ finalStatus = "canceled";
131
+ finalError = interrupted.terminationReason === "killed"
132
+ ? "Task was terminated by kill_task during verification."
133
+ : "Task was canceled during verification.";
134
+ }
135
+ else if (interrupted?.terminationReason === "timeout" || Date.now() >= deadlineMs) {
136
+ finalError = `Task timed out after ${timeoutSeconds} seconds during verification.`;
137
+ }
138
+ else if (failedVerification) {
139
+ finalStatus = "failed_verification";
140
+ finalError = failedVerification.spawnError
141
+ ? `Verification command \"${failedVerification.command}\" could not start: ${failedVerification.spawnError}`
142
+ : `Verification command \"${failedVerification.command}\" exited with code ${failedVerification.exitCode}.`;
143
+ }
144
+ else if (verifyResults.length === verifyCommands.length) {
145
+ finalStatus = "done";
146
+ }
147
+ else {
148
+ finalError = "Verification did not complete all configured commands.";
149
+ }
150
+ }
151
+ else {
152
+ testResult = skippedTest(testCommand, repoPath, "No verification command configured.");
153
+ finalStatus = "done";
154
+ }
155
+ }
156
+ catch (error) {
157
+ finalError = errorMessage(error);
158
+ }
159
+ setTaskPhase(taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
160
+ let changes;
161
+ try {
162
+ const afterSnapshot = captureRepoSnapshot(repoPath);
163
+ writeSnapshot(taskDir, "git-after.json", afterSnapshot);
164
+ changes = buildChangeArtifacts(repoPath, beforeSnapshot, afterSnapshot);
165
+ }
166
+ catch (error) {
167
+ changes = {
168
+ changed_files: [],
169
+ diff: `(change capture failed: ${errorMessage(error)})\n`,
170
+ diff_available: false,
171
+ diff_truncated: false,
172
+ diff_size_bytes: 0,
173
+ additions: 0,
174
+ deletions: 0,
175
+ file_stats: [],
176
+ workspace_dirty_before: beforeSnapshot.workspace_dirty,
177
+ workspace_dirty_after: beforeSnapshot.workspace_dirty,
178
+ patch_mode: "hash_only",
179
+ unavailable_reason: `Change capture failed: ${errorMessage(error)}`,
180
+ };
181
+ finalError ||= `Change capture failed: ${errorMessage(error)}`;
182
+ finalStatus = "failed";
183
+ }
184
+ let outOfScopeChanges = [];
185
+ try {
186
+ const afterWorkspaceSnapshot = repoPath === wsRoot ? captureRepoSnapshot(repoPath) : captureRepoSnapshot(wsRoot);
187
+ writeSnapshot(taskDir, "workspace-after.json", afterWorkspaceSnapshot);
188
+ outOfScopeChanges = compareSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
189
+ .filter((file) => !isPathInside(resolve(wsRoot, file.path), repoPath) ||
190
+ Boolean(file.old_path && !isPathInside(resolve(wsRoot, file.old_path), repoPath)));
191
+ }
192
+ catch (error) {
193
+ finalError ||= `Workspace scope capture failed: ${errorMessage(error)}`;
194
+ if (finalStatus === "done")
195
+ finalStatus = "failed";
196
+ }
197
+ if (outOfScopeChanges.length > 0) {
198
+ finalStatus = "failed_scope_violation";
199
+ finalError = `Detected ${outOfScopeChanges.length} change(s) outside resolved_repo_path.`;
200
+ writeFileSync(join(taskDir, "rollback-plan.json"), JSON.stringify({
201
+ task_id: taskId,
202
+ status: "review_required",
203
+ automatic_rollback_performed: false,
204
+ warning: "Review ownership and concurrent edits before rollback. PatchWarden did not modify or restore these files.",
205
+ out_of_scope_changes: outOfScopeChanges,
206
+ }, null, 2), "utf-8");
207
+ writeFileSync(join(taskDir, "rollback_scope_violation_plan.md"), [
208
+ "# Scope Violation Rollback Plan",
209
+ "",
210
+ `Task: ${taskId}`,
211
+ "",
212
+ "PatchWarden did not automatically roll back any file. Review concurrent or user-owned edits before acting.",
213
+ "",
214
+ "## Out-of-scope files only",
215
+ ...outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`),
216
+ "",
217
+ ].join("\n"), "utf-8");
218
+ }
219
+ else if (changePolicy === "no_changes" && changes.changed_files.length > 0) {
220
+ finalStatus = "failed_policy_violation";
221
+ finalError = `Task policy requires no repository changes, but detected ${changes.changed_files.length} change(s).`;
222
+ }
223
+ writeFileSync(join(taskDir, "git.diff"), changes.diff, "utf-8");
224
+ writeFileSync(join(taskDir, "diff.patch"), changes.diff, "utf-8");
225
+ writeFileSync(join(taskDir, "changed-files.json"), JSON.stringify(changes, null, 2), "utf-8");
226
+ writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
227
+ task_id: taskId,
228
+ additions: changes.additions,
229
+ deletions: changes.deletions,
230
+ files: changes.file_stats,
231
+ }, null, 2), "utf-8");
232
+ writeFileSync(join(taskDir, "test.log"), buildTestLog(testResult), "utf-8");
233
+ const verifyJson = buildVerifyJson(verifyCommands, verifyResults, repoPath);
234
+ if (outOfScopeChanges.length > 0) {
235
+ verifyJson.status = "failed";
236
+ verifyJson.failure_reason = "scope_violation";
237
+ }
238
+ else if (finalStatus === "failed_policy_violation") {
239
+ verifyJson.status = "failed";
240
+ verifyJson.failure_reason = "change_policy_violation";
241
+ }
242
+ writeFileSync(join(taskDir, "verify.json"), JSON.stringify(verifyJson, null, 2), "utf-8");
243
+ writeFileSync(join(taskDir, "verify.log"), buildVerifyLog(verifyJson.commands), "utf-8");
244
+ if (!["canceled", "done", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(finalStatus))
245
+ finalStatus = "failed";
246
+ const finalPhase = finalStatus === "done" ? "completed" : finalStatus;
247
+ const followup = buildFailureFollowup(finalStatus, finalError, verifyJson.commands);
248
+ const resultMd = buildResultMarkdown({
249
+ taskId,
250
+ planId,
251
+ agent: agentName,
252
+ status: finalStatus,
253
+ error: finalError,
254
+ agentResult,
255
+ testResult,
256
+ verify: verifyJson,
257
+ changes,
258
+ outOfScopeChanges,
259
+ });
260
+ writeFileSync(join(taskDir, "result.md"), resultMd, "utf-8");
261
+ writeFileSync(join(taskDir, "result.json"), JSON.stringify({
262
+ task_id: taskId,
263
+ status: finalStatus,
264
+ agent: agentName,
265
+ workspace_root: wsRoot,
266
+ repo_path: initialStatus.repo_path,
267
+ resolved_repo_path: repoPath,
268
+ plan_source: initialStatus.plan_source || "saved",
269
+ template: initialStatus.template || null,
270
+ change_policy: changePolicy,
271
+ summary: finalError || "Agent execution and configured verification completed successfully.",
272
+ changed_files: changes.changed_files,
273
+ out_of_scope_changes: outOfScopeChanges,
274
+ verify_status: verifyJson.status,
275
+ verify_commands: verifyJson.commands,
276
+ commands_run: verifyJson.commands.map((command) => ({
277
+ command: command.command,
278
+ cwd: command.cwd,
279
+ exit_code: command.exit_code,
280
+ })),
281
+ commands_observed: [],
282
+ verify: verifyJson,
283
+ artifacts: [
284
+ "result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "changed-files.json", "file-stats.json",
285
+ ...(outOfScopeChanges.length > 0 ? ["rollback_scope_violation_plan.md", "rollback-plan.json"] : []),
286
+ ],
287
+ warnings: [
288
+ ...beforeSnapshot.warnings,
289
+ ...(changes.diff_truncated ? ["diff.patch was truncated; changed-files.json retains file evidence."] : []),
290
+ ],
291
+ errors: finalError ? [finalError] : [],
292
+ known_issues: finalError ? [finalError] : [],
293
+ failure_reason: followup.failure_reason,
294
+ failed_command: followup.failed_command,
295
+ suggested_next_action: followup.suggested_next_action,
296
+ safe_followup_prompt: followup.safe_followup_prompt,
297
+ next_steps: finalStatus === "done"
298
+ ? ["Review get_task_summary and audit_task before accepting the work."]
299
+ : ["Resolve the reported failure before accepting the work."],
300
+ }, null, 2), "utf-8");
301
+ if (finalError)
302
+ writeFileSync(join(taskDir, "error.log"), finalError, "utf-8");
303
+ const finishedAt = new Date().toISOString();
304
+ updateStatus(taskDir, {
305
+ status: finalStatus,
306
+ phase: finalPhase,
307
+ current_command: null,
308
+ last_heartbeat_at: finishedAt,
309
+ finished_at: finishedAt,
310
+ error: finalError,
311
+ changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
312
+ out_of_scope_changes: outOfScopeChanges,
313
+ verify_status: verifyJson.status,
314
+ verify_commands: verifyCommands,
315
+ diff_available: changes.diff_available,
316
+ diff_truncated: changes.diff_truncated,
317
+ workspace_dirty_before: changes.workspace_dirty_before,
318
+ workspace_dirty_after: changes.workspace_dirty_after,
319
+ workspace_dirty: changes.workspace_dirty_after,
320
+ });
321
+ writeTaskRuntime(taskDir, {
322
+ phase: finalPhase,
323
+ current_command: null,
324
+ last_heartbeat_at: finishedAt,
325
+ runner_pid: process.pid,
326
+ child_pid: undefined,
327
+ });
328
+ writeTaskProgress(taskDir, finalPhase, {
329
+ heartbeatAt: finishedAt,
330
+ note: finalError || `Task finished with status ${finalStatus}.`,
331
+ });
332
+ return { task_id: taskId, status: finalStatus, error: finalError };
333
+ }
334
+ function buildFailureFollowup(status, error, commands) {
335
+ const failed = commands.find((command) => command.status !== "passed");
336
+ if (status === "done") {
337
+ return {
338
+ failure_reason: null,
339
+ failed_command: null,
340
+ suggested_next_action: "audit_task",
341
+ safe_followup_prompt: null,
342
+ };
343
+ }
344
+ if (status === "failed_verification") {
345
+ return {
346
+ failure_reason: error || "Independent verification failed.",
347
+ failed_command: failed?.command || null,
348
+ suggested_next_action: "create_followup_task",
349
+ safe_followup_prompt: `Fix the failing verification${failed?.command ? ` (${failed.command})` : ""} inside the same repository. Do not change unrelated files, weaken checks, commit, push, or publish.`,
350
+ };
351
+ }
352
+ if (status === "failed_scope_violation") {
353
+ return {
354
+ failure_reason: error || "Changes were detected outside resolved_repo_path.",
355
+ failed_command: null,
356
+ suggested_next_action: "review_scope_violation",
357
+ safe_followup_prompt: "Review rollback_scope_violation_plan.md and prepare a backup-first recovery proposal. Do not automatically delete, reset, or restore files.",
358
+ };
359
+ }
360
+ if (status === "failed_policy_violation") {
361
+ return {
362
+ failure_reason: error || "The task violated its no-changes policy.",
363
+ failed_command: null,
364
+ suggested_next_action: "review_unexpected_changes",
365
+ safe_followup_prompt: "Inspect the unexpected repository changes and propose a backup-first recovery. Do not automatically revert or delete files.",
366
+ };
367
+ }
368
+ if (status === "canceled") {
369
+ return {
370
+ failure_reason: error || "Task was canceled.",
371
+ failed_command: failed?.command || null,
372
+ suggested_next_action: "inspect_task_logs",
373
+ safe_followup_prompt: null,
374
+ };
375
+ }
376
+ return {
377
+ failure_reason: error || "Task execution failed.",
378
+ failed_command: failed?.command || null,
379
+ suggested_next_action: "inspect_task_logs",
380
+ safe_followup_prompt: "Inspect result.json, stderr.log, and verify.json, then create a narrowly scoped follow-up task inside the same repository.",
381
+ };
382
+ }
383
+ async function runManagedProcess(options) {
384
+ if (Date.now() >= options.deadlineMs) {
385
+ return { exitCode: null, stdout: "", stderr: "", spawnError: null, terminationReason: "timeout" };
386
+ }
387
+ let child;
388
+ try {
389
+ child = spawn(options.command, options.args, {
390
+ cwd: options.cwd,
391
+ stdio: ["ignore", "pipe", "pipe"],
392
+ detached: process.platform !== "win32",
393
+ windowsHide: true,
394
+ });
395
+ }
396
+ catch (error) {
397
+ return { exitCode: null, stdout: "", stderr: "", spawnError: errorMessage(error), terminationReason: null };
398
+ }
399
+ const stdoutStream = openStream(options.stdoutPath);
400
+ const stderrStream = openStream(options.stderrPath);
401
+ let stdout = "";
402
+ let stderr = "";
403
+ let spawnError = null;
404
+ let terminationReason = null;
405
+ let forceTimer = null;
406
+ let terminationStarted = false;
407
+ const heartbeat = () => {
408
+ const now = new Date().toISOString();
409
+ const activePhase = terminationReason
410
+ ? (terminationReason === "canceled" ? "canceling" : "terminating")
411
+ : options.phase;
412
+ writeTaskRuntime(options.taskDir, {
413
+ phase: activePhase,
414
+ last_heartbeat_at: now,
415
+ current_command: options.currentCommand,
416
+ runner_pid: process.pid,
417
+ child_pid: child.pid,
418
+ });
419
+ writeTaskProgress(options.taskDir, activePhase, {
420
+ heartbeatAt: now,
421
+ currentCommand: options.currentCommand,
422
+ });
423
+ const control = readStatus(options.statusFile);
424
+ if (control.force_kill_requested)
425
+ requestTermination("killed", true);
426
+ else if (control.cancel_requested)
427
+ requestTermination("canceled", false);
428
+ else if (Date.now() >= options.deadlineMs)
429
+ requestTermination("timeout", true);
430
+ };
431
+ const requestTermination = (reason, force) => {
432
+ if (terminationStarted)
433
+ return;
434
+ terminationStarted = true;
435
+ terminationReason = reason;
436
+ const phase = force ? "terminating" : "canceling";
437
+ setTaskPhase(options.taskDir, phase, options.currentCommand, `${reason} requested; stopping child process.`);
438
+ if (force) {
439
+ forceKill(child);
440
+ }
441
+ else {
442
+ gracefulKill(child);
443
+ forceTimer = setTimeout(() => forceKill(child), GRACEFUL_KILL_MS);
444
+ }
445
+ };
446
+ child.stdout?.on("data", (chunk) => {
447
+ const text = chunk.toString("utf-8");
448
+ stdout = appendBounded(stdout, text);
449
+ stdoutStream?.write(text);
450
+ });
451
+ child.stderr?.on("data", (chunk) => {
452
+ const text = chunk.toString("utf-8");
453
+ stderr = appendBounded(stderr, text);
454
+ stderrStream?.write(text);
455
+ });
456
+ heartbeat();
457
+ const heartbeatTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL_MS);
458
+ const exitCode = await new Promise((resolveExit) => {
459
+ let settled = false;
460
+ const finish = (code) => {
461
+ if (settled)
462
+ return;
463
+ settled = true;
464
+ resolveExit(code);
465
+ };
466
+ child.once("close", (code) => finish(code));
467
+ child.once("error", (error) => {
468
+ spawnError = error.message;
469
+ finish(null);
470
+ });
471
+ });
472
+ clearInterval(heartbeatTimer);
473
+ if (forceTimer)
474
+ clearTimeout(forceTimer);
475
+ stdoutStream?.end();
476
+ stderrStream?.end();
477
+ writeTaskRuntime(options.taskDir, {
478
+ phase: options.phase,
479
+ last_heartbeat_at: new Date().toISOString(),
480
+ current_command: null,
481
+ runner_pid: process.pid,
482
+ child_pid: undefined,
483
+ });
484
+ return { exitCode, stdout, stderr, spawnError, terminationReason };
485
+ }
486
+ async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile, deadlineMs) {
487
+ const config = getConfig();
488
+ const trusted = guardTestCommand(testCommand, config);
489
+ const startedAtMs = Date.now();
490
+ const startedAt = new Date(startedAtMs).toISOString();
491
+ const parts = trusted.split(/\s+/).filter(Boolean);
492
+ let command = parts[0];
493
+ let args = parts.slice(1);
494
+ if (process.platform === "win32" && /^(npm|npm\.cmd|pnpm|pnpm\.cmd)$/i.test(command)) {
495
+ const shim = /^pnpm/i.test(command) ? "pnpm.cmd" : "npm.cmd";
496
+ command = process.env.ComSpec || "cmd.exe";
497
+ args = ["/d", "/s", "/c", shim, ...args];
498
+ }
499
+ const result = await runManagedProcess({
500
+ command,
501
+ args,
502
+ cwd: repoPath,
503
+ taskDir,
504
+ statusFile,
505
+ phase: "running_tests",
506
+ currentCommand: trusted,
507
+ deadlineMs,
508
+ stdoutPath: join(taskDir, "test.stdout.log"),
509
+ stderrPath: join(taskDir, "test.stderr.log"),
510
+ });
511
+ const finishedAtMs = Date.now();
512
+ return {
513
+ ...result,
514
+ command: trusted,
515
+ cwd: repoPath,
516
+ started_at: startedAt,
517
+ finished_at: new Date(finishedAtMs).toISOString(),
518
+ duration_ms: finishedAtMs - startedAtMs,
519
+ };
520
+ }
521
+ function buildExecutionPrompt(plan, repoPath, testCommand) {
522
+ let prompt = `You are executing a pre-written plan in a local repository.
523
+
524
+ ## Repository
525
+ ${repoPath}
526
+
527
+ ## Plan
528
+ ${plan}
529
+
530
+ ## Instructions
531
+ 1. Read the plan carefully.
532
+ 2. Implement the changes in this repository only.
533
+ 3. Do NOT modify files outside this repository.
534
+ 4. Do NOT commit or push changes.
535
+ 5. After implementing, describe what you changed.
536
+ 6. Output a summary with what was done, files modified, and issues encountered.
537
+ `;
538
+ if (testCommand) {
539
+ prompt += `\n7. You may run ${testCommand}; PatchWarden will independently run it again for verification.`;
540
+ }
541
+ return prompt;
542
+ }
543
+ function buildTestLog(result) {
544
+ if (result.skipped)
545
+ return `${result.command || "(no test command)"}\nExit code: not run\n${result.stderr}\n`;
546
+ return [
547
+ `$ ${result.command}`,
548
+ `Exit code: ${result.exitCode}`,
549
+ result.terminationReason ? `Termination: ${result.terminationReason}` : "",
550
+ result.spawnError ? `Spawn error: ${result.spawnError}` : "",
551
+ "",
552
+ result.stdout || "(no output)",
553
+ result.stderr ? `\nSTDERR:\n${result.stderr}` : "",
554
+ ].filter((line) => line !== "").join("\n");
555
+ }
556
+ function buildResultMarkdown(input) {
557
+ const changed = input.changes.changed_files.length
558
+ ? input.changes.changed_files.map((file) => `- ${file.change}: ${file.path}`).join("\n")
559
+ : "(no task file changes detected)";
560
+ const outOfScope = input.outOfScopeChanges.length
561
+ ? input.outOfScopeChanges.map((file) => `- ${file.change}: ${file.old_path ? `${file.old_path} -> ` : ""}${file.path}`).join("\n")
562
+ : "(none)";
563
+ return [
564
+ "# PatchWarden Task Result",
565
+ "",
566
+ "## Status",
567
+ input.status,
568
+ "",
569
+ "## Agent",
570
+ input.agent,
571
+ "",
572
+ "## Plan",
573
+ input.planId,
574
+ "",
575
+ "## Completed",
576
+ new Date().toISOString(),
577
+ "",
578
+ "## Files changed",
579
+ changed,
580
+ "",
581
+ "## Verification",
582
+ `- diff_available: ${input.changes.diff_available}`,
583
+ `- diff_truncated: ${input.changes.diff_truncated}`,
584
+ `- workspace_dirty_before: ${input.changes.workspace_dirty_before}`,
585
+ `- workspace_dirty_after: ${input.changes.workspace_dirty_after}`,
586
+ `- verify_status: ${input.verify.status}`,
587
+ `- verify_commands: ${input.verify.commands.length}/${input.verify.requested_commands.length} executed`,
588
+ `- out_of_scope_changes: ${input.outOfScopeChanges.length}`,
589
+ "",
590
+ "## Out-of-scope changes",
591
+ outOfScope,
592
+ "",
593
+ "## Summary",
594
+ input.error || "Agent execution and configured verification completed successfully.",
595
+ "",
596
+ "## Risks",
597
+ input.status === "done"
598
+ ? "- Review git.diff and changed-files.json before accepting the task."
599
+ : "- Task did not complete successfully; outputs may be partial.",
600
+ "",
601
+ "---",
602
+ "",
603
+ "## Agent stdout",
604
+ "",
605
+ "```",
606
+ input.agentResult?.stdout || "(no output)",
607
+ "```",
608
+ "",
609
+ "## Agent stderr",
610
+ "",
611
+ "```",
612
+ input.agentResult?.stderr || "(empty)",
613
+ "```",
614
+ ].join("\n");
615
+ }
616
+ function skippedTest(command, cwd, reason) {
617
+ const now = new Date().toISOString();
618
+ return {
619
+ command,
620
+ cwd,
621
+ started_at: now,
622
+ finished_at: now,
623
+ duration_ms: 0,
624
+ skipped: true,
625
+ exitCode: null,
626
+ stdout: "",
627
+ stderr: reason,
628
+ spawnError: null,
629
+ terminationReason: null,
630
+ };
631
+ }
632
+ function buildVerifyJson(requested, results, cwd) {
633
+ const commands = results.map((result) => ({
634
+ command: result.command,
635
+ cwd: result.cwd || cwd,
636
+ exit_code: result.exitCode,
637
+ stdout_tail: summarizeOutput(result.stdout),
638
+ stderr_tail: summarizeOutput(result.spawnError || result.stderr),
639
+ started_at: result.started_at,
640
+ finished_at: result.finished_at,
641
+ duration_ms: result.duration_ms,
642
+ status: result.terminationReason === "timeout"
643
+ ? "timed_out"
644
+ : result.terminationReason
645
+ ? "canceled"
646
+ : result.exitCode === 0 && !result.spawnError
647
+ ? "passed"
648
+ : "failed",
649
+ }));
650
+ const status = requested.length === 0
651
+ ? "skipped"
652
+ : commands.length === requested.length && commands.every((command) => command.status === "passed")
653
+ ? "passed"
654
+ : "failed";
655
+ return { status, requested_commands: requested, commands };
656
+ }
657
+ function buildVerifyLog(commands) {
658
+ if (commands.length === 0)
659
+ return "Verification skipped: no verify_commands configured.\n";
660
+ return commands.map((entry) => [
661
+ `$ ${entry.command}`,
662
+ `cwd: ${entry.cwd}`,
663
+ `status: ${entry.status}`,
664
+ `exit_code: ${entry.exit_code}`,
665
+ `started_at: ${entry.started_at}`,
666
+ `finished_at: ${entry.finished_at}`,
667
+ `duration_ms: ${entry.duration_ms}`,
668
+ "stdout:",
669
+ entry.stdout_tail || "(empty)",
670
+ "stderr:",
671
+ entry.stderr_tail || "(empty)",
672
+ ].join("\n")).join("\n\n");
673
+ }
674
+ function normalizeVerifyCommands(value, legacyTestCommand, config) {
675
+ if (value !== undefined && !Array.isArray(value)) {
676
+ throw new Error("Invalid task verify_commands metadata; expected an array.");
677
+ }
678
+ return [...new Set([
679
+ ...(value || []).map((command) => guardTestCommand(String(command), config)),
680
+ ...(legacyTestCommand ? [guardTestCommand(legacyTestCommand, config)] : []),
681
+ ])];
682
+ }
683
+ function summarizeOutput(value) {
684
+ const trimmed = (value || "").trim();
685
+ return trimmed.length <= 4000 ? trimmed : `${trimmed.slice(0, 4000)}\n...(truncated)`;
686
+ }
687
+ function isPathInside(candidate, root) {
688
+ const rel = relative(root, candidate);
689
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
690
+ }
691
+ function normalizeTimeout(value, config) {
692
+ const timeout = value == null ? config.defaultTaskTimeoutSeconds : Number(value);
693
+ if (!Number.isInteger(timeout) || timeout <= 0 || timeout > config.maxTaskTimeoutSeconds) {
694
+ throw new Error(`Invalid task timeout. Expected 1-${config.maxTaskTimeoutSeconds} seconds.`);
695
+ }
696
+ return timeout;
697
+ }
698
+ function failBeforeExecution(taskId, taskDir, message) {
699
+ writeFileSync(join(taskDir, "error.log"), message, "utf-8");
700
+ const current = readStatus(join(taskDir, "status.json"));
701
+ const now = new Date().toISOString();
702
+ const verify = {
703
+ status: "failed",
704
+ requested_commands: Array.isArray(current.verify_commands) ? current.verify_commands : [],
705
+ commands: [],
706
+ };
707
+ writeFileSync(join(taskDir, "verify.json"), JSON.stringify(verify, null, 2), "utf-8");
708
+ writeFileSync(join(taskDir, "verify.log"), `Verification did not run: ${message}\n`, "utf-8");
709
+ writeFileSync(join(taskDir, "test.log"), `(not run)\nExit code: not run\n${message}\n`, "utf-8");
710
+ writeFileSync(join(taskDir, "git.diff"), "(task failed before change capture)\n", "utf-8");
711
+ writeFileSync(join(taskDir, "diff.patch"), "(task failed before change capture)\n", "utf-8");
712
+ writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
713
+ task_id: taskId,
714
+ additions: 0,
715
+ deletions: 0,
716
+ files: [],
717
+ }, null, 2), "utf-8");
718
+ const result = {
719
+ task_id: taskId,
720
+ status: "failed",
721
+ agent: current.agent || "",
722
+ workspace_root: current.workspace_root || "",
723
+ repo_path: current.repo_path || "",
724
+ resolved_repo_path: current.resolved_repo_path || "",
725
+ summary: message,
726
+ changed_files: [],
727
+ out_of_scope_changes: [],
728
+ verify_status: verify.status,
729
+ verify_commands: verify.commands,
730
+ commands_run: [],
731
+ commands_observed: [],
732
+ verify,
733
+ artifacts: ["result.md", "result.json", "diff.patch", "git.diff", "test.log", "verify.log", "verify.json", "file-stats.json"],
734
+ warnings: [],
735
+ errors: [message],
736
+ known_issues: [message],
737
+ failure_reason: message,
738
+ failed_command: null,
739
+ suggested_next_action: "inspect_task_logs",
740
+ safe_followup_prompt: "Inspect result.json and error.log, correct the task metadata or configuration, and retry without widening repository scope.",
741
+ next_steps: ["Fix task metadata or configuration and retry the task."],
742
+ };
743
+ writeFileSync(join(taskDir, "result.json"), JSON.stringify(result, null, 2), "utf-8");
744
+ writeFileSync(join(taskDir, "result.md"), `# PatchWarden Task Result\n\n## Status\nfailed\n\n## Summary\n${message}\n`, "utf-8");
745
+ updateStatus(taskDir, {
746
+ status: "failed",
747
+ phase: "failed",
748
+ error: message,
749
+ finished_at: now,
750
+ verify_status: "failed",
751
+ changed_files: [],
752
+ out_of_scope_changes: [],
753
+ });
754
+ writeTaskProgress(taskDir, "failed", { note: message });
755
+ return { task_id: taskId, status: "failed", error: message };
756
+ }
757
+ function setTaskPhase(taskDir, phase, currentCommand, note) {
758
+ const now = new Date().toISOString();
759
+ updateStatus(taskDir, { phase, current_command: currentCommand, last_heartbeat_at: now });
760
+ writeTaskRuntime(taskDir, {
761
+ phase,
762
+ current_command: currentCommand,
763
+ last_heartbeat_at: now,
764
+ runner_pid: process.pid,
765
+ });
766
+ writeTaskProgress(taskDir, phase, { currentCommand, heartbeatAt: now, note });
767
+ }
768
+ function updateStatus(taskDir, patch) {
769
+ const statusFile = join(taskDir, "status.json");
770
+ const current = readStatus(statusFile);
771
+ const next = { ...current, ...patch, updated_at: new Date().toISOString() };
772
+ writeFileSync(statusFile, JSON.stringify(next, null, 2), "utf-8");
773
+ }
774
+ function readStatus(statusFile) {
775
+ return JSON.parse(readFileSync(statusFile, "utf-8"));
776
+ }
777
+ function gracefulKill(child) {
778
+ try {
779
+ if (process.platform !== "win32" && child.pid)
780
+ process.kill(-child.pid, "SIGTERM");
781
+ else
782
+ child.kill("SIGTERM");
783
+ }
784
+ catch { }
785
+ }
786
+ function forceKill(child) {
787
+ if (!child.pid)
788
+ return;
789
+ try {
790
+ if (process.platform === "win32") {
791
+ const result = spawnSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
792
+ stdio: "ignore",
793
+ timeout: 5000,
794
+ windowsHide: true,
795
+ });
796
+ if (result.status !== 0)
797
+ child.kill("SIGKILL");
798
+ }
799
+ else {
800
+ process.kill(-child.pid, "SIGKILL");
801
+ }
802
+ }
803
+ catch {
804
+ try {
805
+ child.kill("SIGKILL");
806
+ }
807
+ catch { }
808
+ }
809
+ }
810
+ function openStream(path) {
811
+ return path ? createWriteStream(path, { flags: "a" }) : null;
812
+ }
813
+ function appendBounded(current, next) {
814
+ const combined = current + next;
815
+ return combined.length <= MAX_CAPTURE_CHARS
816
+ ? combined
817
+ : combined.slice(combined.length - MAX_CAPTURE_CHARS);
818
+ }
819
+ function errorMessage(error) {
820
+ return error instanceof Error ? error.message : String(error);
821
+ }