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/dist/index.js CHANGED
@@ -11,14 +11,17 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import { loadConfig } from "./config.js";
12
12
  import { registerTools } from "./tools/registry.js";
13
13
  import { PATCHWARDEN_VERSION } from "./version.js";
14
+ import { logger } from "./logging.js";
14
15
  const config = loadConfig();
15
- console.error(`[patchwarden] Workspace: ${config.workspaceRoot}`);
16
- console.error(`[patchwarden] Transport: stdio`);
16
+ logger.info(`[patchwarden] Workspace: ${config.workspaceRoot}`);
17
+ logger.info("[patchwarden] Transport: stdio");
17
18
  const server = new Server({ name: "patchwarden", version: PATCHWARDEN_VERSION }, { capabilities: { tools: {} } });
18
19
  registerTools(server);
19
20
  const transport = new StdioServerTransport();
20
21
  server.connect(transport).catch((err) => {
21
- console.error("[patchwarden] Fatal:", err);
22
+ logger.fatal("[patchwarden] Fatal", {
23
+ error: err instanceof Error ? err.message : String(err),
24
+ });
22
25
  process.exit(1);
23
26
  });
24
- console.error("[patchwarden] MCP server ready on stdio");
27
+ logger.info("[patchwarden] MCP server ready on stdio");
package/dist/logging.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type LogLevel = "info" | "warn" | "error" | "audit";
1
+ export type LogLevel = "info" | "warn" | "error" | "fatal" | "audit";
2
2
  export interface LogEntry {
3
3
  timestamp: string;
4
4
  level: LogLevel;
@@ -39,6 +39,12 @@ export declare class Logger {
39
39
  info(message: string, context?: Record<string, unknown>): void;
40
40
  warn(message: string, context?: Record<string, unknown>): void;
41
41
  error(message: string, context?: Record<string, unknown>): void;
42
+ /**
43
+ * Emit a fatal-level log entry. Use this immediately before a process
44
+ * exits with a non-zero status, to record the unrecoverable condition.
45
+ * Level is higher than `error`.
46
+ */
47
+ fatal(message: string, context?: Record<string, unknown>): void;
42
48
  /**
43
49
  * Emit a tool-call audit log entry.
44
50
  *
package/dist/logging.js CHANGED
@@ -51,6 +51,14 @@ export class Logger {
51
51
  error(message, context) {
52
52
  emit({ timestamp: new Date().toISOString(), level: "error", message, ...context });
53
53
  }
54
+ /**
55
+ * Emit a fatal-level log entry. Use this immediately before a process
56
+ * exits with a non-zero status, to record the unrecoverable condition.
57
+ * Level is higher than `error`.
58
+ */
59
+ fatal(message, context) {
60
+ emit({ timestamp: new Date().toISOString(), level: "fatal", message, ...context });
61
+ }
54
62
  /**
55
63
  * Emit a tool-call audit log entry.
56
64
  *
@@ -66,9 +66,9 @@ export interface ChangeArtifacts {
66
66
  unavailable_reason: string | null;
67
67
  artifact_hygiene: ArtifactHygiene;
68
68
  }
69
- export declare function captureRepoSnapshot(repoPath: string): RepoSnapshot;
69
+ export declare function captureRepoSnapshot(repoPath: string): Promise<RepoSnapshot>;
70
70
  export declare function writeSnapshot(taskDir: string, filename: string, snapshot: RepoSnapshot): void;
71
- export declare function buildChangeArtifacts(repoPath: string, before: RepoSnapshot, after: RepoSnapshot): ChangeArtifacts;
71
+ export declare function buildChangeArtifacts(repoPath: string, before: RepoSnapshot, after: RepoSnapshot): Promise<ChangeArtifacts>;
72
72
  export declare function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): ChangedFile[];
73
73
  export declare function emptyArtifactHygiene(): ArtifactHygiene;
74
74
  export interface ExternalDirtyFile {
@@ -101,7 +101,7 @@ export interface ArtifactManifest {
101
101
  generated_at: string;
102
102
  artifacts: ArtifactManifestEntry[];
103
103
  }
104
- export declare function buildArtifactManifest(changedFiles: ChangedFile[], repoPath: string, taskId?: string): ArtifactManifest;
104
+ export declare function buildArtifactManifest(changedFiles: ChangedFile[], repoPath: string, taskId?: string): Promise<ArtifactManifest>;
105
105
  export interface ChangedFileGroups {
106
106
  source_changes: ChangedFile[];
107
107
  docs_changes: ChangedFile[];
@@ -1,15 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lstatSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
2
+ import { createReadStream, lstatSync, readFileSync, readdirSync, writeFileSync, } from "node:fs";
3
3
  import { join, relative, resolve, isAbsolute } from "node:path";
4
- import { spawnSync } from "node:child_process";
4
+ import { execFile } from "node:child_process";
5
5
  import { isSensitivePath } from "../security/sensitiveGuard.js";
6
6
  const MAX_HASH_BYTES = 5 * 1024 * 1024;
7
7
  const MAX_SNAPSHOT_FILES = 5000;
8
8
  const MAX_DIFF_BYTES = 20 * 1024 * 1024;
9
9
  const SKIP_DIRECTORIES = new Set([".git", ".patchwarden", "node_modules"]);
10
- export function captureRepoSnapshot(repoPath) {
10
+ export async function captureRepoSnapshot(repoPath) {
11
11
  const warnings = [];
12
- const isGit = runGit(repoPath, ["rev-parse", "--is-inside-work-tree"]).stdout.trim() === "true";
12
+ const isGitResult = await runGit(repoPath, ["rev-parse", "--is-inside-work-tree"]);
13
+ const isGit = isGitResult.stdout.trim() === "true";
13
14
  let head = null;
14
15
  let status = "";
15
16
  let paths = [];
@@ -17,10 +18,17 @@ export function captureRepoSnapshot(repoPath) {
17
18
  const ignoredPaths = new Set();
18
19
  const dirtyPaths = new Set();
19
20
  if (isGit) {
20
- const headResult = runGit(repoPath, ["rev-parse", "HEAD"]);
21
+ // The following five git calls are independent of each other and can run in parallel.
22
+ const [headResult, statusResult, trackedResult, ignoredResult, listedResult] = await Promise.all([
23
+ runGit(repoPath, ["rev-parse", "HEAD"]),
24
+ runGit(repoPath, ["status", "--porcelain=v1", "-uall"]),
25
+ runGit(repoPath, ["ls-files", "-z"]),
26
+ runGit(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "-z"]),
27
+ runGit(repoPath, ["ls-files", "-co", "--exclude-standard", "-z"]),
28
+ ]);
21
29
  if (headResult.status === 0)
22
30
  head = headResult.stdout.trim() || null;
23
- status = runGit(repoPath, ["status", "--porcelain=v1", "-uall"]).stdout.trimEnd();
31
+ status = statusResult.stdout.trimEnd();
24
32
  // Parse git status --porcelain to collect all dirty paths
25
33
  for (const line of status.split("\n")) {
26
34
  if (line.length < 4)
@@ -45,23 +53,20 @@ export function captureRepoSnapshot(repoPath) {
45
53
  }
46
54
  }
47
55
  }
48
- const tracked = runGit(repoPath, ["ls-files", "-z"]);
49
- if (tracked.status === 0) {
50
- for (const path of tracked.stdout.split("\0").filter(Boolean))
56
+ if (trackedResult.status === 0) {
57
+ for (const path of trackedResult.stdout.split("\0").filter(Boolean))
51
58
  trackedPaths.add(normalizePath(path));
52
59
  }
53
- const ignored = runGit(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "-z"]);
54
- if (ignored.status === 0) {
55
- for (const path of ignored.stdout.split("\0").filter(Boolean))
60
+ if (ignoredResult.status === 0) {
61
+ for (const path of ignoredResult.stdout.split("\0").filter(Boolean))
56
62
  ignoredPaths.add(normalizePath(path));
57
63
  }
58
64
  else {
59
65
  warnings.push("git ignored-file discovery failed; ignored classification may be incomplete");
60
66
  }
61
- const listed = runGit(repoPath, ["ls-files", "-co", "--exclude-standard", "-z"]);
62
- if (listed.status === 0) {
67
+ if (listedResult.status === 0) {
63
68
  paths = [...new Set([
64
- ...listed.stdout.split("\0").filter(Boolean),
69
+ ...listedResult.stdout.split("\0").filter(Boolean),
65
70
  ...walkWorkspace(repoPath),
66
71
  ])];
67
72
  }
@@ -116,26 +121,26 @@ export function captureRepoSnapshot(repoPath) {
116
121
  export function writeSnapshot(taskDir, filename, snapshot) {
117
122
  writeFileSync(join(taskDir, filename), JSON.stringify(snapshot, null, 2), "utf-8");
118
123
  }
119
- export function buildChangeArtifacts(repoPath, before, after) {
124
+ export async function buildChangeArtifacts(repoPath, before, after) {
120
125
  const changedFiles = compareSnapshots(before, after);
121
126
  const artifactHygiene = classifyArtifactHygiene(changedFiles);
122
127
  const sections = [];
123
128
  const scopedPaths = [...new Set(changedFiles.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))];
124
129
  if (before.is_git && after.is_git && scopedPaths.length > 0) {
125
130
  if (before.head && after.head && before.head !== after.head) {
126
- const committed = runGit(repoPath, ["diff", "--no-color", "--binary", before.head, after.head, "--", ...scopedPaths]);
131
+ const committed = await runGit(repoPath, ["diff", "--no-color", "--binary", before.head, after.head, "--", ...scopedPaths]);
127
132
  if (committed.stdout.trim())
128
133
  sections.push("# Changes committed during task\n", committed.stdout.trimEnd());
129
134
  }
130
135
  const base = after.head || "HEAD";
131
- const working = runGit(repoPath, ["diff", "--no-color", "--binary", base, "--", ...scopedPaths]);
136
+ const working = await runGit(repoPath, ["diff", "--no-color", "--binary", base, "--", ...scopedPaths]);
132
137
  if (working.stdout.trim())
133
138
  sections.push("# Staged and unstaged changes\n", working.stdout.trimEnd());
134
139
  for (const file of changedFiles.filter((item) => item.change === "added").slice(0, 100)) {
135
- const tracked = runGit(repoPath, ["ls-files", "--error-unmatch", "--", file.path]);
140
+ const tracked = await runGit(repoPath, ["ls-files", "--error-unmatch", "--", file.path]);
136
141
  if (tracked.status === 0)
137
142
  continue;
138
- const untracked = runGit(repoPath, ["diff", "--no-index", "--no-color", "--binary", "--", "/dev/null", file.path]);
143
+ const untracked = await runGit(repoPath, ["diff", "--no-index", "--no-color", "--binary", "--", "/dev/null", file.path]);
139
144
  if (untracked.stdout.trim())
140
145
  sections.push("# Untracked file\n", untracked.stdout.trimEnd());
141
146
  }
@@ -151,7 +156,7 @@ export function buildChangeArtifacts(repoPath, before, after) {
151
156
  const fullDiff = `${evidence}\n\n${body || (changedFiles.length ? "(textual patch unavailable; see changed-files.json for hash evidence)" : "(no task file changes detected)")}\n`;
152
157
  const additions = fullDiff.split(/\r?\n/).filter((line) => line.startsWith("+") && !line.startsWith("+++")).length;
153
158
  const deletions = fullDiff.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
154
- const fileStats = buildFileStats(repoPath, before, after, changedFiles);
159
+ const fileStats = await buildFileStats(repoPath, before, after, changedFiles);
155
160
  return {
156
161
  changed_files: changedFiles,
157
162
  diff: fullDiff,
@@ -172,8 +177,8 @@ export function buildChangeArtifacts(repoPath, before, after) {
172
177
  artifact_hygiene: artifactHygiene,
173
178
  };
174
179
  }
175
- function buildFileStats(repoPath, before, after, changedFiles) {
176
- return changedFiles.map((file) => {
180
+ async function buildFileStats(repoPath, before, after, changedFiles) {
181
+ const results = await Promise.all(changedFiles.map(async (file) => {
177
182
  let additions = 0;
178
183
  let deletions = 0;
179
184
  const paths = file.old_path ? [file.old_path, file.path] : [file.path];
@@ -183,8 +188,8 @@ function buildFileStats(repoPath, before, after, changedFiles) {
183
188
  ranges.push([before.head, after.head]);
184
189
  }
185
190
  ranges.push([after.head || "HEAD"]);
186
- for (const range of ranges) {
187
- const result = runGit(repoPath, ["diff", "--numstat", ...range, "--", ...paths]);
191
+ const rangeResults = await Promise.all(ranges.map((range) => runGit(repoPath, ["diff", "--numstat", ...range, "--", ...paths])));
192
+ for (const result of rangeResults) {
188
193
  for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
189
194
  const [added, removed] = line.split(/\s+/);
190
195
  if (/^\d+$/.test(added))
@@ -199,10 +204,11 @@ function buildFileStats(repoPath, before, after, changedFiles) {
199
204
  const content = readFileSync(resolve(repoPath, file.path), "utf-8");
200
205
  additions = countLines(content);
201
206
  }
202
- catch { }
207
+ catch { } // probe failure handled by return value (additions stays 0)
203
208
  }
204
209
  return { path: file.path, status: file.change, additions, deletions };
205
- });
210
+ }));
211
+ return results;
206
212
  }
207
213
  function countLines(content) {
208
214
  if (content.length === 0)
@@ -318,7 +324,7 @@ export function findNewExternalDirtyFiles(baseline, current) {
318
324
  return false;
319
325
  });
320
326
  }
321
- export function buildArtifactManifest(changedFiles, repoPath, taskId) {
327
+ export async function buildArtifactManifest(changedFiles, repoPath, taskId) {
322
328
  const entries = [];
323
329
  for (const file of changedFiles) {
324
330
  if (file.kind !== "build_artifact")
@@ -331,7 +337,7 @@ export function buildArtifactManifest(changedFiles, repoPath, taskId) {
331
337
  if (stat.isFile()) {
332
338
  size = stat.size;
333
339
  if (size <= MAX_HASH_BYTES) {
334
- sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
340
+ sha256 = await hashFileAsync(absolutePath);
335
341
  }
336
342
  }
337
343
  }
@@ -503,15 +509,33 @@ function walkWorkspace(root) {
503
509
  return result;
504
510
  }
505
511
  function runGit(repoPath, args) {
506
- const result = spawnSync("git", args, {
507
- cwd: repoPath,
508
- encoding: "utf-8",
509
- timeout: 30_000,
510
- maxBuffer: MAX_DIFF_BYTES,
512
+ return new Promise((resolve) => {
513
+ execFile("git", args, {
514
+ cwd: repoPath,
515
+ encoding: "utf-8",
516
+ timeout: 30_000,
517
+ maxBuffer: MAX_DIFF_BYTES,
518
+ }, (err, stdout, stderr) => {
519
+ let status;
520
+ if (err) {
521
+ // err.code is the exit code (number) when the process exited with a non-zero status;
522
+ // it is a string (e.g. "ENOENT") when spawning failed or the buffer limit was hit.
523
+ status = typeof err.code === "number" ? err.code : null;
524
+ }
525
+ else {
526
+ status = 0;
527
+ }
528
+ resolve({ status, stdout: stdout || "", stderr: stderr || "" });
529
+ });
530
+ });
531
+ }
532
+ function hashFileAsync(filePath) {
533
+ return new Promise((resolve, reject) => {
534
+ const hash = createHash("sha256");
535
+ const stream = createReadStream(filePath);
536
+ stream.on("error", reject);
537
+ hash.on("error", reject);
538
+ hash.on("finish", () => resolve(hash.digest("hex")));
539
+ stream.pipe(hash);
511
540
  });
512
- return {
513
- status: result.status,
514
- stdout: result.stdout || "",
515
- stderr: result.stderr || "",
516
- };
517
541
  }
@@ -8,19 +8,20 @@
8
8
  */
9
9
  import { runTask } from "./runTask.js";
10
10
  import { loadConfig } from "../config.js";
11
+ import { logger } from "../logging.js";
11
12
  // Load config early
12
13
  loadConfig();
13
14
  const taskId = process.argv[2] || process.env.PATCHWARDEN_TASK_ID;
14
15
  if (!taskId) {
15
- console.error("Usage: node dist/runner/cli.js <task_id>");
16
- console.error(" or: npm run runner -- <task_id>");
16
+ logger.info("Usage: node dist/runner/cli.js <task_id>");
17
+ logger.info(" or: npm run runner -- <task_id>");
17
18
  process.exit(1);
18
19
  }
19
- console.error(`[runner] Starting task: ${taskId}`);
20
+ logger.info(`[runner] Starting task: ${taskId}`);
20
21
  const result = await runTask(taskId);
21
- console.error(`[runner] Task ${result.task_id}: ${result.status}`);
22
+ logger.info(`[runner] Task ${result.task_id}: ${result.status}`);
22
23
  if (result.error) {
23
- console.error(`[runner] Error: ${result.error}`);
24
+ logger.fatal(`[runner] Error: ${result.error}`);
24
25
  process.exit(1);
25
26
  }
26
- console.error("[runner] Done.");
27
+ 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
+ const isWindows = platform() === "win32";
6
+ /**
7
+ * Remove a file or directory, working around Node.js fs.rmSync issues on Windows
8
+ * where recursive directory deletion silently fails (Node v22+).
9
+ * Falls back to `rmdir /s /q` on Windows if rmSync doesn't remove the path.
10
+ */
11
+ function safeRemoveSync(targetPath) {
12
+ rmSync(targetPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
13
+ if (isWindows && existsSync(targetPath)) {
14
+ try {
15
+ const stat = statSync(targetPath);
16
+ if (stat.isDirectory()) {
17
+ execFileSync("cmd", ["/c", "rmdir", "/s", "/q", targetPath], { windowsHide: true });
18
+ }
19
+ else {
20
+ execFileSync("cmd", ["/c", "del", "/f", "/q", targetPath], { windowsHide: true });
21
+ }
22
+ }
23
+ catch {
24
+ // Best effort — caller will verify via existsSync if needed
25
+ }
26
+ }
27
+ }
4
28
  const EXCLUDED_DIRS = new Set([".git", ".patchwarden", ".venv", "node_modules", "samples", "docs"]);
5
29
  const CLEANUP_DIRS = new Set(["__pycache__", "dist", "release_packages", "temp_files"]);
6
30
  const CLEANUP_FILES = [/\.pyc$/i];
@@ -28,7 +52,7 @@ export function runPostTaskCleanup(repoPath, taskDir) {
28
52
  continue;
29
53
  }
30
54
  try {
31
- rmSync(candidate.path, { recursive: true, force: true });
55
+ safeRemoveSync(candidate.path);
32
56
  report.removed.push({ path: rel, reason: candidate.reason });
33
57
  }
34
58
  catch (error) {