patchwarden 1.5.0 → 1.5.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 (217) hide show
  1. package/README.en.md +43 -2
  2. package/README.md +43 -2
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2197
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/doctor.d.ts +18 -1
  44. package/dist/doctor.js +579 -348
  45. package/dist/goal/goalReport.d.ts +54 -0
  46. package/dist/goal/goalReport.js +204 -0
  47. package/dist/goal/goalStatus.d.ts +6 -0
  48. package/dist/goal/specKitImport.d.ts +63 -0
  49. package/dist/goal/specKitImport.js +220 -0
  50. package/dist/goal/subgoalSync.js +2 -1
  51. package/dist/httpServer.js +15 -12
  52. package/dist/index.js +7 -4
  53. package/dist/logging.d.ts +7 -1
  54. package/dist/logging.js +8 -0
  55. package/dist/runner/changeCapture.d.ts +3 -3
  56. package/dist/runner/changeCapture.js +63 -39
  57. package/dist/runner/cli.js +7 -6
  58. package/dist/runner/postTaskCleanup.js +26 -2
  59. package/dist/runner/runTask.js +245 -221
  60. package/dist/runner/simpleProcess.js +4 -4
  61. package/dist/runner/watch.js +17 -14
  62. package/dist/security/contentRedaction.d.ts +6 -0
  63. package/dist/security/contentRedaction.js +22 -0
  64. package/dist/smoke-test.js +257 -251
  65. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  66. package/dist/test/unit/apply-patch.test.js +225 -0
  67. package/dist/test/unit/create-task.test.d.ts +1 -0
  68. package/dist/test/unit/create-task.test.js +197 -0
  69. package/dist/test/unit/direct-guards.test.js +124 -9
  70. package/dist/test/unit/evidence-pack.test.js +95 -1
  71. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  72. package/dist/test/unit/get-task-status.test.js +174 -0
  73. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  74. package/dist/test/unit/get-task-summary.test.js +146 -0
  75. package/dist/test/unit/goal-report.test.d.ts +1 -0
  76. package/dist/test/unit/goal-report.test.js +159 -0
  77. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  78. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  79. package/dist/test/unit/path-guard.test.js +24 -0
  80. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  81. package/dist/test/unit/spec-kit-import.test.js +341 -0
  82. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  83. package/dist/test/unit/wait-for-task.test.js +144 -0
  84. package/dist/tools/auditTask.d.ts +8 -63
  85. package/dist/tools/auditTask.js +12 -8
  86. package/dist/tools/createDirectSession.d.ts +1 -1
  87. package/dist/tools/createDirectSession.js +2 -2
  88. package/dist/tools/createTask.d.ts +2 -2
  89. package/dist/tools/createTask.js +4 -4
  90. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  91. package/dist/tools/dispatch/coreDispatch.js +282 -0
  92. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  93. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  94. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  95. package/dist/tools/dispatch/directDispatch.js +115 -0
  96. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  97. package/dist/tools/dispatch/goalDispatch.js +91 -0
  98. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  99. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  100. package/dist/tools/dispatch/types.d.ts +23 -0
  101. package/dist/tools/dispatch/types.js +15 -0
  102. package/dist/tools/evidencePack.d.ts +6 -0
  103. package/dist/tools/evidencePack.js +213 -6
  104. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  105. package/dist/tools/finalizeDirectSession.js +3 -3
  106. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  107. package/dist/tools/goalSubgoalTask.js +2 -2
  108. package/dist/tools/healthCheck.js +3 -3
  109. package/dist/tools/registry.d.ts +3 -3
  110. package/dist/tools/registry.js +60 -503
  111. package/dist/tools/retryTask.d.ts +2 -2
  112. package/dist/tools/retryTask.js +2 -2
  113. package/dist/tools/runTaskLoop.js +4 -4
  114. package/dist/tools/safeViews.d.ts +2 -2
  115. package/dist/tools/safeViews.js +2 -2
  116. package/dist/tools/toolRegistry.js +22 -0
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/docs/CODE_WIKI.md +983 -0
  120. package/docs/agentseal-integration.md +150 -0
  121. package/docs/architecture.md +63 -0
  122. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  123. package/docs/dashboard-overview.md +86 -0
  124. package/docs/demo.md +8 -0
  125. package/docs/direct-session-workflow.md +98 -0
  126. package/docs/evidence-pack-schema.md +215 -0
  127. package/docs/execution-plan-2026-07-09.md +315 -0
  128. package/docs/lineage-evidence-pack-workflow.md +127 -0
  129. package/docs/mcp-inspector-testing.md +200 -0
  130. package/docs/open-source-application.md +162 -0
  131. package/docs/opencode-worker.md +151 -0
  132. package/docs/openhands-worker.md +181 -0
  133. package/docs/release-evidence.md +72 -0
  134. package/docs/roadmap-execution-and-acceptance.md +365 -0
  135. package/docs/spec-kit-integration.md +131 -0
  136. package/docs/task-safe-review-workflow.md +98 -0
  137. package/docs/threat-model.md +79 -0
  138. package/docs/user-feedback.md +40 -0
  139. package/docs/why-patchwarden.md +110 -0
  140. package/package.json +2 -2
  141. package/scripts/checks/control-center-smoke.js +356 -0
  142. package/scripts/checks/lifecycle-smoke.js +29 -23
  143. package/scripts/checks/mcp-smoke.js +3 -1
  144. package/scripts/generate-demo-gif.py +320 -0
  145. package/src/assessments/agentAssessor.ts +3 -3
  146. package/src/assessments/assessmentStore.ts +2 -2
  147. package/src/assessments/confirmCli.ts +5 -4
  148. package/src/config.ts +37 -0
  149. package/src/control/middleware/auth.ts +17 -0
  150. package/src/control/middleware/static.ts +71 -0
  151. package/src/control/routes/audit.ts +321 -0
  152. package/src/control/routes/evidence.ts +107 -0
  153. package/src/control/routes/lineage.ts +92 -0
  154. package/src/control/routes/policy.ts +81 -0
  155. package/src/control/routes/process.ts +204 -0
  156. package/src/control/routes/sessions.ts +251 -0
  157. package/src/control/routes/status.ts +325 -0
  158. package/src/control/routes/taskActions.ts +248 -0
  159. package/src/control/routes/tasks.ts +323 -0
  160. package/src/control/routes/workspace.ts +203 -0
  161. package/src/control/runtime.ts +472 -0
  162. package/src/control/server.ts +471 -0
  163. package/src/control/shared.ts +294 -0
  164. package/src/controlCenter.ts +7 -2347
  165. package/src/direct/directGuards.ts +28 -7
  166. package/src/doctor.ts +741 -481
  167. package/src/goal/goalReport.ts +271 -0
  168. package/src/goal/goalStatus.ts +6 -0
  169. package/src/goal/specKitImport.ts +355 -0
  170. package/src/goal/subgoalSync.ts +4 -2
  171. package/src/httpServer.ts +17 -14
  172. package/src/index.ts +7 -4
  173. package/src/logging.ts +10 -1
  174. package/src/runner/changeCapture.ts +70 -42
  175. package/src/runner/cli.ts +7 -6
  176. package/src/runner/postTaskCleanup.ts +26 -2
  177. package/src/runner/runTask.ts +325 -223
  178. package/src/runner/simpleProcess.ts +4 -4
  179. package/src/runner/watch.ts +17 -14
  180. package/src/security/contentRedaction.ts +29 -0
  181. package/src/smoke-test.ts +252 -250
  182. package/src/test/unit/apply-patch.test.ts +293 -0
  183. package/src/test/unit/create-task.test.ts +255 -0
  184. package/src/test/unit/direct-guards.test.ts +178 -8
  185. package/src/test/unit/evidence-pack.test.ts +110 -1
  186. package/src/test/unit/get-task-status.test.ts +203 -0
  187. package/src/test/unit/get-task-summary.test.ts +173 -0
  188. package/src/test/unit/goal-report.test.ts +189 -0
  189. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  190. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  191. package/src/test/unit/path-guard.test.ts +30 -0
  192. package/src/test/unit/spec-kit-import.test.ts +429 -0
  193. package/src/test/unit/wait-for-task.test.ts +176 -0
  194. package/src/tools/auditTask.ts +99 -59
  195. package/src/tools/createDirectSession.ts +3 -3
  196. package/src/tools/createTask.ts +7 -7
  197. package/src/tools/dispatch/coreDispatch.ts +374 -0
  198. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  199. package/src/tools/dispatch/directDispatch.ts +167 -0
  200. package/src/tools/dispatch/goalDispatch.ts +127 -0
  201. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  202. package/src/tools/dispatch/types.ts +24 -0
  203. package/src/tools/evidencePack.ts +291 -6
  204. package/src/tools/finalizeDirectSession.ts +4 -4
  205. package/src/tools/goalSubgoalTask.ts +2 -2
  206. package/src/tools/healthCheck.ts +3 -3
  207. package/src/tools/registry.ts +68 -628
  208. package/src/tools/retryTask.ts +2 -2
  209. package/src/tools/runTaskLoop.ts +4 -4
  210. package/src/tools/safeViews.ts +2 -2
  211. package/src/tools/toolRegistry.ts +22 -0
  212. package/src/version.ts +1 -1
  213. package/ui/pages/audit.html +192 -3
  214. package/ui/pages/dashboard.html +959 -44
  215. package/ui/pages/direct-sessions.html +505 -53
  216. package/ui/pages/task-detail.html +456 -438
  217. package/ui/pages/tasks.html +598 -61
package/src/httpServer.ts CHANGED
@@ -25,29 +25,30 @@ import { registerTools } from "./tools/registry.js";
25
25
  import { healthCheck } from "./tools/healthCheck.js";
26
26
  import { getToolCatalogSnapshot } from "./tools/registry.js";
27
27
  import { PATCHWARDEN_VERSION } from "./version.js";
28
+ import { logger } from "./logging.js";
28
29
 
29
30
  // ── Bootstrap ─────────────────────────────────────────────────────
30
31
 
31
32
  const config = loadConfig();
32
33
  const port = parseInt(process.env.PATCHWARDEN_HTTP_PORT || "") ||
33
- (config as any).httpPort ||
34
+ config.httpPort ||
34
35
  7331;
35
36
  const host = "127.0.0.1";
36
37
 
37
- console.error(`[patchwarden-http] Workspace: ${config.workspaceRoot}`);
38
- console.error(`[patchwarden-http] Listening: http://${host}:${port}/mcp`);
39
- console.error(`[patchwarden-http] ⚠️ Bound to 127.0.0.1 only — not exposed to network`);
38
+ logger.info(`[patchwarden-http] Workspace: ${config.workspaceRoot}`);
39
+ logger.info(`[patchwarden-http] Listening: http://${host}:${port}/mcp`);
40
+ logger.info(`[patchwarden-http] ⚠️ Bound to 127.0.0.1 only — not exposed to network`);
40
41
 
41
42
  // ── Owner token (optional) ────────────────────────────────────────
42
43
 
43
- const httpCfg = (config as any).http || {};
44
+ const httpCfg = config.http || {};
44
45
  const ownerTokenEnv = httpCfg.ownerTokenEnv || "PATCHWARDEN_OWNER_TOKEN";
45
46
  const ownerToken = process.env[ownerTokenEnv] || "";
46
47
 
47
48
  if (ownerToken) {
48
- console.error(`[patchwarden-http] 🔒 Owner token required (env: ${ownerTokenEnv})`);
49
+ logger.info(`[patchwarden-http] 🔒 Owner token required (env: ${ownerTokenEnv})`);
49
50
  } else {
50
- console.error(`[patchwarden-http] ⚠️ No owner token set — all local requests accepted`);
51
+ logger.info(`[patchwarden-http] ⚠️ No owner token set — all local requests accepted`);
51
52
  }
52
53
 
53
54
  function checkOwnerToken(req: IncomingMessage): boolean {
@@ -142,7 +143,9 @@ async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Prom
142
143
  await mcpServer.connect(transport);
143
144
  await transport.handleRequest(req, res);
144
145
  } catch (err) {
145
- console.error("[patchwarden-http] Request error:", err);
146
+ logger.error("[patchwarden-http] Request error", {
147
+ error: err instanceof Error ? err.message : String(err),
148
+ });
146
149
  if (!res.headersSent) {
147
150
  res.writeHead(500, { "Content-Type": "application/json" });
148
151
  res.end(JSON.stringify({ error: "Internal server error" }));
@@ -253,22 +256,22 @@ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse
253
256
 
254
257
  httpServer.on("error", (err: NodeJS.ErrnoException) => {
255
258
  if (err.code === "EADDRINUSE") {
256
- console.error(`[patchwarden-http] Fatal: port ${port} is already in use on ${host}.`);
257
- console.error("[patchwarden-http] Stop the other PatchWarden HTTP instance or change httpPort in patchwarden.config.json.");
259
+ logger.fatal(`[patchwarden-http] Fatal: port ${port} is already in use on ${host}.`);
260
+ logger.fatal("[patchwarden-http] Stop the other PatchWarden HTTP instance or change httpPort in patchwarden.config.json.");
258
261
  } else {
259
- console.error(`[patchwarden-http] Fatal: ${err.message}`);
262
+ logger.fatal(`[patchwarden-http] Fatal: ${err.message}`);
260
263
  }
261
264
  process.exit(1);
262
265
  });
263
266
 
264
267
  httpServer.listen(port, host, () => {
265
- console.error(`[patchwarden-http] ✅ Ready`);
266
- console.error(`[patchwarden-http] Admin: http://${host}:${port}/admin/tasks/:id/accept`);
268
+ logger.info(`[patchwarden-http] ✅ Ready`);
269
+ logger.info(`[patchwarden-http] Admin: http://${host}:${port}/admin/tasks/:id/accept`);
267
270
  });
268
271
 
269
272
  // Graceful shutdown
270
273
  process.on("SIGINT", () => {
271
- console.error("[patchwarden-http] Shutting down...");
274
+ logger.info("[patchwarden-http] Shutting down...");
272
275
  httpServer.close(() => process.exit(0));
273
276
  });
274
277
 
package/src/index.ts CHANGED
@@ -12,11 +12,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
12
12
  import { loadConfig } from "./config.js";
13
13
  import { registerTools } from "./tools/registry.js";
14
14
  import { PATCHWARDEN_VERSION } from "./version.js";
15
+ import { logger } from "./logging.js";
15
16
 
16
17
  const config = loadConfig();
17
18
 
18
- console.error(`[patchwarden] Workspace: ${config.workspaceRoot}`);
19
- console.error(`[patchwarden] Transport: stdio`);
19
+ logger.info(`[patchwarden] Workspace: ${config.workspaceRoot}`);
20
+ logger.info("[patchwarden] Transport: stdio");
20
21
 
21
22
  const server = new Server(
22
23
  { name: "patchwarden", version: PATCHWARDEN_VERSION },
@@ -27,8 +28,10 @@ registerTools(server);
27
28
 
28
29
  const transport = new StdioServerTransport();
29
30
  server.connect(transport).catch((err) => {
30
- console.error("[patchwarden] Fatal:", err);
31
+ logger.fatal("[patchwarden] Fatal", {
32
+ error: err instanceof Error ? err.message : String(err),
33
+ });
31
34
  process.exit(1);
32
35
  });
33
36
 
34
- console.error("[patchwarden] MCP server ready on stdio");
37
+ logger.info("[patchwarden] MCP server ready on stdio");
package/src/logging.ts CHANGED
@@ -7,7 +7,7 @@ import { getConfig } from "./config.js";
7
7
 
8
8
  // ── Types ─────────────────────────────────────────────────────────
9
9
 
10
- export type LogLevel = "info" | "warn" | "error" | "audit";
10
+ export type LogLevel = "info" | "warn" | "error" | "fatal" | "audit";
11
11
 
12
12
  export interface LogEntry {
13
13
  timestamp: string;
@@ -90,6 +90,15 @@ export class Logger {
90
90
  emit({ timestamp: new Date().toISOString(), level: "error", message, ...context });
91
91
  }
92
92
 
93
+ /**
94
+ * Emit a fatal-level log entry. Use this immediately before a process
95
+ * exits with a non-zero status, to record the unrecoverable condition.
96
+ * Level is higher than `error`.
97
+ */
98
+ fatal(message: string, context?: Record<string, unknown>): void {
99
+ emit({ timestamp: new Date().toISOString(), level: "fatal", message, ...context });
100
+ }
101
+
93
102
  /**
94
103
  * Emit a tool-call audit log entry.
95
104
  *
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import {
3
+ createReadStream,
3
4
  existsSync,
4
5
  lstatSync,
5
6
  readFileSync,
@@ -8,7 +9,7 @@ import {
8
9
  writeFileSync,
9
10
  } from "node:fs";
10
11
  import { join, relative, resolve, isAbsolute } from "node:path";
11
- import { spawnSync } from "node:child_process";
12
+ import { execFile } from "node:child_process";
12
13
  import { isSensitivePath } from "../security/sensitiveGuard.js";
13
14
 
14
15
  const MAX_HASH_BYTES = 5 * 1024 * 1024;
@@ -90,9 +91,10 @@ export interface ChangeArtifacts {
90
91
  artifact_hygiene: ArtifactHygiene;
91
92
  }
92
93
 
93
- export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
94
+ export async function captureRepoSnapshot(repoPath: string): Promise<RepoSnapshot> {
94
95
  const warnings: string[] = [];
95
- const isGit = runGit(repoPath, ["rev-parse", "--is-inside-work-tree"]).stdout.trim() === "true";
96
+ const isGitResult = await runGit(repoPath, ["rev-parse", "--is-inside-work-tree"]);
97
+ const isGit = isGitResult.stdout.trim() === "true";
96
98
  let head: string | null = null;
97
99
  let status = "";
98
100
  let paths: string[] = [];
@@ -101,9 +103,17 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
101
103
 
102
104
  const dirtyPaths = new Set<string>();
103
105
  if (isGit) {
104
- const headResult = runGit(repoPath, ["rev-parse", "HEAD"]);
106
+ // The following five git calls are independent of each other and can run in parallel.
107
+ const [headResult, statusResult, trackedResult, ignoredResult, listedResult] = await Promise.all([
108
+ runGit(repoPath, ["rev-parse", "HEAD"]),
109
+ runGit(repoPath, ["status", "--porcelain=v1", "-uall"]),
110
+ runGit(repoPath, ["ls-files", "-z"]),
111
+ runGit(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "-z"]),
112
+ runGit(repoPath, ["ls-files", "-co", "--exclude-standard", "-z"]),
113
+ ]);
114
+
105
115
  if (headResult.status === 0) head = headResult.stdout.trim() || null;
106
- status = runGit(repoPath, ["status", "--porcelain=v1", "-uall"]).stdout.trimEnd();
116
+ status = statusResult.stdout.trimEnd();
107
117
  // Parse git status --porcelain to collect all dirty paths
108
118
  for (const line of status.split("\n")) {
109
119
  if (line.length < 4) continue;
@@ -125,20 +135,17 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
125
135
  }
126
136
  }
127
137
  }
128
- const tracked = runGit(repoPath, ["ls-files", "-z"]);
129
- if (tracked.status === 0) {
130
- for (const path of tracked.stdout.split("\0").filter(Boolean)) trackedPaths.add(normalizePath(path));
138
+ if (trackedResult.status === 0) {
139
+ for (const path of trackedResult.stdout.split("\0").filter(Boolean)) trackedPaths.add(normalizePath(path));
131
140
  }
132
- const ignored = runGit(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "-z"]);
133
- if (ignored.status === 0) {
134
- for (const path of ignored.stdout.split("\0").filter(Boolean)) ignoredPaths.add(normalizePath(path));
141
+ if (ignoredResult.status === 0) {
142
+ for (const path of ignoredResult.stdout.split("\0").filter(Boolean)) ignoredPaths.add(normalizePath(path));
135
143
  } else {
136
144
  warnings.push("git ignored-file discovery failed; ignored classification may be incomplete");
137
145
  }
138
- const listed = runGit(repoPath, ["ls-files", "-co", "--exclude-standard", "-z"]);
139
- if (listed.status === 0) {
146
+ if (listedResult.status === 0) {
140
147
  paths = [...new Set([
141
- ...listed.stdout.split("\0").filter(Boolean),
148
+ ...listedResult.stdout.split("\0").filter(Boolean),
142
149
  ...walkWorkspace(repoPath),
143
150
  ])];
144
151
  } else {
@@ -193,11 +200,11 @@ export function writeSnapshot(taskDir: string, filename: string, snapshot: RepoS
193
200
  writeFileSync(join(taskDir, filename), JSON.stringify(snapshot, null, 2), "utf-8");
194
201
  }
195
202
 
196
- export function buildChangeArtifacts(
203
+ export async function buildChangeArtifacts(
197
204
  repoPath: string,
198
205
  before: RepoSnapshot,
199
206
  after: RepoSnapshot
200
- ): ChangeArtifacts {
207
+ ): Promise<ChangeArtifacts> {
201
208
  const changedFiles = compareSnapshots(before, after);
202
209
  const artifactHygiene = classifyArtifactHygiene(changedFiles);
203
210
  const sections: string[] = [];
@@ -205,18 +212,18 @@ export function buildChangeArtifacts(
205
212
 
206
213
  if (before.is_git && after.is_git && scopedPaths.length > 0) {
207
214
  if (before.head && after.head && before.head !== after.head) {
208
- const committed = runGit(repoPath, ["diff", "--no-color", "--binary", before.head, after.head, "--", ...scopedPaths]);
215
+ const committed = await runGit(repoPath, ["diff", "--no-color", "--binary", before.head, after.head, "--", ...scopedPaths]);
209
216
  if (committed.stdout.trim()) sections.push("# Changes committed during task\n", committed.stdout.trimEnd());
210
217
  }
211
218
 
212
219
  const base = after.head || "HEAD";
213
- const working = runGit(repoPath, ["diff", "--no-color", "--binary", base, "--", ...scopedPaths]);
220
+ const working = await runGit(repoPath, ["diff", "--no-color", "--binary", base, "--", ...scopedPaths]);
214
221
  if (working.stdout.trim()) sections.push("# Staged and unstaged changes\n", working.stdout.trimEnd());
215
222
 
216
223
  for (const file of changedFiles.filter((item) => item.change === "added").slice(0, 100)) {
217
- const tracked = runGit(repoPath, ["ls-files", "--error-unmatch", "--", file.path]);
224
+ const tracked = await runGit(repoPath, ["ls-files", "--error-unmatch", "--", file.path]);
218
225
  if (tracked.status === 0) continue;
219
- const untracked = runGit(repoPath, ["diff", "--no-index", "--no-color", "--binary", "--", "/dev/null", file.path]);
226
+ const untracked = await runGit(repoPath, ["diff", "--no-index", "--no-color", "--binary", "--", "/dev/null", file.path]);
220
227
  if (untracked.stdout.trim()) sections.push("# Untracked file\n", untracked.stdout.trimEnd());
221
228
  }
222
229
  }
@@ -232,7 +239,7 @@ export function buildChangeArtifacts(
232
239
  const fullDiff = `${evidence}\n\n${body || (changedFiles.length ? "(textual patch unavailable; see changed-files.json for hash evidence)" : "(no task file changes detected)")}\n`;
233
240
  const additions = fullDiff.split(/\r?\n/).filter((line) => line.startsWith("+") && !line.startsWith("+++")).length;
234
241
  const deletions = fullDiff.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
235
- const fileStats = buildFileStats(repoPath, before, after, changedFiles);
242
+ const fileStats = await buildFileStats(repoPath, before, after, changedFiles);
236
243
 
237
244
  return {
238
245
  changed_files: changedFiles,
@@ -255,13 +262,13 @@ export function buildChangeArtifacts(
255
262
  };
256
263
  }
257
264
 
258
- function buildFileStats(
265
+ async function buildFileStats(
259
266
  repoPath: string,
260
267
  before: RepoSnapshot,
261
268
  after: RepoSnapshot,
262
269
  changedFiles: ChangedFile[]
263
- ): ChangeArtifacts["file_stats"] {
264
- return changedFiles.map((file) => {
270
+ ): Promise<ChangeArtifacts["file_stats"]> {
271
+ const results = await Promise.all(changedFiles.map(async (file) => {
265
272
  let additions = 0;
266
273
  let deletions = 0;
267
274
  const paths = file.old_path ? [file.old_path, file.path] : [file.path];
@@ -272,8 +279,10 @@ function buildFileStats(
272
279
  ranges.push([before.head, after.head]);
273
280
  }
274
281
  ranges.push([after.head || "HEAD"]);
275
- for (const range of ranges) {
276
- const result = runGit(repoPath, ["diff", "--numstat", ...range, "--", ...paths]);
282
+ const rangeResults = await Promise.all(
283
+ ranges.map((range) => runGit(repoPath, ["diff", "--numstat", ...range, "--", ...paths]))
284
+ );
285
+ for (const result of rangeResults) {
277
286
  for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
278
287
  const [added, removed] = line.split(/\s+/);
279
288
  if (/^\d+$/.test(added)) additions += Number(added);
@@ -286,11 +295,12 @@ function buildFileStats(
286
295
  try {
287
296
  const content = readFileSync(resolve(repoPath, file.path), "utf-8");
288
297
  additions = countLines(content);
289
- } catch {}
298
+ } catch {} // probe failure handled by return value (additions stays 0)
290
299
  }
291
300
 
292
301
  return { path: file.path, status: file.change, additions, deletions };
293
- });
302
+ }));
303
+ return results;
294
304
  }
295
305
 
296
306
  function countLines(content: string): number {
@@ -441,11 +451,11 @@ export interface ArtifactManifest {
441
451
  artifacts: ArtifactManifestEntry[];
442
452
  }
443
453
 
444
- export function buildArtifactManifest(
454
+ export async function buildArtifactManifest(
445
455
  changedFiles: ChangedFile[],
446
456
  repoPath: string,
447
457
  taskId?: string
448
- ): ArtifactManifest {
458
+ ): Promise<ArtifactManifest> {
449
459
  const entries: ArtifactManifestEntry[] = [];
450
460
  for (const file of changedFiles) {
451
461
  if (file.kind !== "build_artifact") continue;
@@ -457,7 +467,7 @@ export function buildArtifactManifest(
457
467
  if (stat.isFile()) {
458
468
  size = stat.size;
459
469
  if (size <= MAX_HASH_BYTES) {
460
- sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
470
+ sha256 = await hashFileAsync(absolutePath);
461
471
  }
462
472
  }
463
473
  } catch {
@@ -635,16 +645,34 @@ function walkWorkspace(root: string): string[] {
635
645
  return result;
636
646
  }
637
647
 
638
- function runGit(repoPath: string, args: string[]) {
639
- const result = spawnSync("git", args, {
640
- cwd: repoPath,
641
- encoding: "utf-8",
642
- timeout: 30_000,
643
- maxBuffer: MAX_DIFF_BYTES,
648
+ function runGit(repoPath: string, args: string[]): Promise<{ status: number | null; stdout: string; stderr: string }> {
649
+ return new Promise((resolve) => {
650
+ execFile("git", args, {
651
+ cwd: repoPath,
652
+ encoding: "utf-8",
653
+ timeout: 30_000,
654
+ maxBuffer: MAX_DIFF_BYTES,
655
+ }, (err, stdout, stderr) => {
656
+ let status: number | null;
657
+ if (err) {
658
+ // err.code is the exit code (number) when the process exited with a non-zero status;
659
+ // it is a string (e.g. "ENOENT") when spawning failed or the buffer limit was hit.
660
+ status = typeof err.code === "number" ? err.code : null;
661
+ } else {
662
+ status = 0;
663
+ }
664
+ resolve({ status, stdout: stdout || "", stderr: stderr || "" });
665
+ });
666
+ });
667
+ }
668
+
669
+ function hashFileAsync(filePath: string): Promise<string> {
670
+ return new Promise((resolve, reject) => {
671
+ const hash = createHash("sha256");
672
+ const stream = createReadStream(filePath);
673
+ stream.on("error", reject);
674
+ hash.on("error", reject);
675
+ hash.on("finish", () => resolve(hash.digest("hex")));
676
+ stream.pipe(hash);
644
677
  });
645
- return {
646
- status: result.status,
647
- stdout: result.stdout || "",
648
- stderr: result.stderr || "",
649
- };
650
678
  }
package/src/runner/cli.ts CHANGED
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { runTask } from "./runTask.js";
11
11
  import { loadConfig } from "../config.js";
12
+ import { logger } from "../logging.js";
12
13
 
13
14
  // Load config early
14
15
  loadConfig();
@@ -16,19 +17,19 @@ loadConfig();
16
17
  const taskId = process.argv[2] || process.env.PATCHWARDEN_TASK_ID;
17
18
 
18
19
  if (!taskId) {
19
- console.error("Usage: node dist/runner/cli.js <task_id>");
20
- console.error(" or: npm run runner -- <task_id>");
20
+ logger.info("Usage: node dist/runner/cli.js <task_id>");
21
+ logger.info(" or: npm run runner -- <task_id>");
21
22
  process.exit(1);
22
23
  }
23
24
 
24
- console.error(`[runner] Starting task: ${taskId}`);
25
+ logger.info(`[runner] Starting task: ${taskId}`);
25
26
 
26
27
  const result = await runTask(taskId);
27
28
 
28
- console.error(`[runner] Task ${result.task_id}: ${result.status}`);
29
+ logger.info(`[runner] Task ${result.task_id}: ${result.status}`);
29
30
  if (result.error) {
30
- console.error(`[runner] Error: ${result.error}`);
31
+ logger.fatal(`[runner] Error: ${result.error}`);
31
32
  process.exit(1);
32
33
  }
33
34
 
34
- console.error("[runner] Done.");
35
+ logger.info("[runner] Done.");
@@ -1,6 +1,30 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { platform } from "node:os";
5
+
6
+ const isWindows = platform() === "win32";
7
+
8
+ /**
9
+ * Remove a file or directory, working around Node.js fs.rmSync issues on Windows
10
+ * where recursive directory deletion silently fails (Node v22+).
11
+ * Falls back to `rmdir /s /q` on Windows if rmSync doesn't remove the path.
12
+ */
13
+ function safeRemoveSync(targetPath: string): void {
14
+ rmSync(targetPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
15
+ if (isWindows && existsSync(targetPath)) {
16
+ try {
17
+ const stat = statSync(targetPath);
18
+ if (stat.isDirectory()) {
19
+ execFileSync("cmd", ["/c", "rmdir", "/s", "/q", targetPath], { windowsHide: true });
20
+ } else {
21
+ execFileSync("cmd", ["/c", "del", "/f", "/q", targetPath], { windowsHide: true });
22
+ }
23
+ } catch {
24
+ // Best effort — caller will verify via existsSync if needed
25
+ }
26
+ }
27
+ }
4
28
 
5
29
  const EXCLUDED_DIRS = new Set([".git", ".patchwarden", ".venv", "node_modules", "samples", "docs"]);
6
30
  const CLEANUP_DIRS = new Set(["__pycache__", "dist", "release_packages", "temp_files"]);
@@ -42,7 +66,7 @@ export function runPostTaskCleanup(repoPath: string, taskDir: string): PostTaskC
42
66
  continue;
43
67
  }
44
68
  try {
45
- rmSync(candidate.path, { recursive: true, force: true });
69
+ safeRemoveSync(candidate.path);
46
70
  report.removed.push({ path: rel, reason: candidate.reason });
47
71
  } catch (error) {
48
72
  report.skipped.push({