patchwarden 1.5.1 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (573) hide show
  1. package/PatchWarden.cmd +32 -2
  2. package/README.en.md +152 -25
  3. package/README.md +120 -23
  4. package/dist/assessments/agentAssessor.js +21 -12
  5. package/dist/assessments/assessmentStore.js +42 -34
  6. package/dist/config.d.ts +15 -0
  7. package/dist/config.js +66 -13
  8. package/dist/control/fileManager.d.ts +2 -0
  9. package/dist/control/fileManager.js +39 -0
  10. package/dist/control/middleware/auth.d.ts +6 -0
  11. package/dist/control/middleware/auth.js +14 -5
  12. package/dist/control/middleware/static.js +16 -3
  13. package/dist/control/routeTable.d.ts +24 -0
  14. package/dist/control/routeTable.js +263 -0
  15. package/dist/control/routes/audit.js +17 -7
  16. package/dist/control/routes/evidence.js +1 -1
  17. package/dist/control/routes/lineage.js +1 -1
  18. package/dist/control/routes/process.d.ts +7 -0
  19. package/dist/control/routes/process.js +329 -42
  20. package/dist/control/routes/sessions.d.ts +9 -1
  21. package/dist/control/routes/sessions.js +84 -21
  22. package/dist/control/routes/status.d.ts +46 -1
  23. package/dist/control/routes/status.js +72 -15
  24. package/dist/control/routes/taskActions.js +24 -60
  25. package/dist/control/routes/tasks.d.ts +4 -0
  26. package/dist/control/routes/tasks.js +61 -62
  27. package/dist/control/routes/workspace.js +50 -23
  28. package/dist/control/runtime.d.ts +3 -2
  29. package/dist/control/runtime.js +63 -30
  30. package/dist/control/server.js +36 -365
  31. package/dist/control/shared.d.ts +25 -1
  32. package/dist/control/shared.js +64 -11
  33. package/dist/direct/directAudit.js +234 -225
  34. package/dist/direct/directGuards.d.ts +2 -1
  35. package/dist/direct/directGuards.js +42 -29
  36. package/dist/direct/directPatch.d.ts +6 -1
  37. package/dist/direct/directPatch.js +55 -8
  38. package/dist/direct/directSessionStore.d.ts +16 -6
  39. package/dist/direct/directSessionStore.js +127 -35
  40. package/dist/direct/directVerification.js +8 -16
  41. package/dist/doctor.js +62 -52
  42. package/dist/goal/acceptanceEngine.d.ts +1 -1
  43. package/dist/goal/goalProgress.js +75 -69
  44. package/dist/goal/goalReport.js +5 -12
  45. package/dist/goal/goalStatus.d.ts +2 -0
  46. package/dist/goal/goalStatus.js +10 -3
  47. package/dist/goal/goalStore.d.ts +9 -2
  48. package/dist/goal/goalStore.js +152 -45
  49. package/dist/goal/handoffExport.js +6 -6
  50. package/dist/goal/specKitImport.js +97 -96
  51. package/dist/goal/subgoalSync.js +11 -9
  52. package/dist/goal/worktreeManager.d.ts +2 -0
  53. package/dist/goal/worktreeManager.js +209 -136
  54. package/dist/httpServer.js +111 -32
  55. package/dist/logging.js +5 -3
  56. package/dist/policy/projectPolicy.js +5 -2
  57. package/dist/release/releaseGate.d.ts +1 -1
  58. package/dist/release/releaseGate.js +78 -25
  59. package/dist/runner/agentInvocation.d.ts +14 -0
  60. package/dist/runner/agentInvocation.js +96 -4
  61. package/dist/runner/changeCapture.d.ts +13 -5
  62. package/dist/runner/changeCapture.js +243 -61
  63. package/dist/runner/postTaskCleanup.js +48 -19
  64. package/dist/runner/processSecurity.d.ts +56 -0
  65. package/dist/runner/processSecurity.js +373 -0
  66. package/dist/runner/runTask.d.ts +1 -1
  67. package/dist/runner/runTask.js +115 -87
  68. package/dist/runner/simpleProcess.d.ts +3 -0
  69. package/dist/runner/simpleProcess.js +50 -36
  70. package/dist/{taskProgress.d.ts → runner/taskProgress.d.ts} +1 -1
  71. package/dist/{taskProgress.js → runner/taskProgress.js} +2 -2
  72. package/dist/{taskRuntime.d.ts → runner/taskRuntime.d.ts} +1 -1
  73. package/dist/{taskRuntime.js → runner/taskRuntime.js} +3 -2
  74. package/dist/runner/taskStatusStore.d.ts +11 -0
  75. package/dist/runner/taskStatusStore.js +24 -0
  76. package/dist/runner/watch.d.ts +14 -1
  77. package/dist/runner/watch.js +256 -48
  78. package/dist/security/contentRedaction.js +2 -2
  79. package/dist/security/discoveryTokenStore.d.ts +3 -2
  80. package/dist/security/discoveryTokenStore.js +17 -2
  81. package/dist/security/loopbackHost.d.ts +2 -0
  82. package/dist/security/loopbackHost.js +16 -0
  83. package/dist/security/pathGuard.js +32 -17
  84. package/dist/security/planGuard.js +2 -1
  85. package/dist/security/runtimeGuard.js +9 -6
  86. package/dist/security/secretComparison.d.ts +3 -0
  87. package/dist/security/secretComparison.js +11 -0
  88. package/dist/security/sensitiveGuard.d.ts +5 -0
  89. package/dist/security/sensitiveGuard.js +27 -8
  90. package/dist/security/toolInvocationGuard.d.ts +2 -2
  91. package/dist/security/toolInvocationGuard.js +1 -1
  92. package/dist/security/workspaceRootGuard.d.ts +8 -0
  93. package/dist/security/workspaceRootGuard.js +47 -0
  94. package/dist/smoke-test.js +24 -23
  95. package/dist/tools/catalog/index.d.ts +4 -0
  96. package/dist/tools/catalog/index.js +4 -0
  97. package/dist/tools/{toolCatalog.js → catalog/toolCatalog.js} +3 -12
  98. package/dist/tools/{toolRegistry.d.ts → catalog/toolRegistry.d.ts} +3 -3
  99. package/dist/tools/{toolRegistry.js → catalog/toolRegistry.js} +6 -15
  100. package/dist/tools/definitions/toolDefs.d.ts +18 -0
  101. package/dist/tools/definitions/toolDefs.js +1177 -0
  102. package/dist/tools/{auditSession.d.ts → diagnostics/auditSession.d.ts} +1 -1
  103. package/dist/tools/{auditSession.js → diagnostics/auditSession.js} +2 -2
  104. package/dist/tools/{auditTask.d.ts → diagnostics/auditTask.d.ts} +1 -1
  105. package/dist/tools/{auditTask.js → diagnostics/auditTask.js} +99 -29
  106. package/dist/tools/{healthCheck.d.ts → diagnostics/healthCheck.d.ts} +6 -6
  107. package/dist/tools/{healthCheck.js → diagnostics/healthCheck.js} +7 -7
  108. package/dist/tools/diagnostics/index.d.ts +6 -0
  109. package/dist/tools/diagnostics/index.js +6 -0
  110. package/dist/tools/{safeStatus.d.ts → diagnostics/safeStatus.d.ts} +3 -3
  111. package/dist/tools/{safeStatus.js → diagnostics/safeStatus.js} +6 -6
  112. package/dist/tools/{safeViews.d.ts → diagnostics/safeViews.d.ts} +12 -8
  113. package/dist/tools/{safeViews.js → diagnostics/safeViews.js} +23 -13
  114. package/dist/tools/{schemaDriftCheck.d.ts → diagnostics/schemaDriftCheck.d.ts} +1 -1
  115. package/dist/tools/{schemaDriftCheck.js → diagnostics/schemaDriftCheck.js} +2 -2
  116. package/dist/tools/{createDirectSession.js → direct/createDirectSession.js} +6 -6
  117. package/dist/tools/{finalizeDirectSession.d.ts → direct/finalizeDirectSession.d.ts} +1 -1
  118. package/dist/tools/direct/finalizeDirectSession.js +86 -0
  119. package/dist/tools/direct/index.d.ts +3 -0
  120. package/dist/tools/direct/index.js +3 -0
  121. package/dist/tools/{runDirectVerificationBundle.js → direct/runDirectVerificationBundle.js} +2 -2
  122. package/dist/tools/{discoverTools.d.ts → discovery/discoverTools.d.ts} +2 -2
  123. package/dist/tools/{discoverTools.js → discovery/discoverTools.js} +5 -5
  124. package/dist/tools/{explainTool.d.ts → discovery/explainTool.d.ts} +2 -2
  125. package/dist/tools/{explainTool.js → discovery/explainTool.js} +2 -2
  126. package/dist/tools/discovery/index.d.ts +3 -0
  127. package/dist/tools/discovery/index.js +3 -0
  128. package/dist/tools/{invokeDiscoveredTool.d.ts → discovery/invokeDiscoveredTool.d.ts} +2 -2
  129. package/dist/tools/{invokeDiscoveredTool.js → discovery/invokeDiscoveredTool.js} +5 -5
  130. package/dist/tools/dispatch/coreDispatch.js +32 -31
  131. package/dist/tools/dispatch/diagnosticDispatch.d.ts +3 -5
  132. package/dist/tools/dispatch/diagnosticDispatch.js +70 -68
  133. package/dist/tools/dispatch/directDispatch.js +11 -10
  134. package/dist/tools/dispatch/goalDispatch.js +5 -4
  135. package/dist/tools/dispatch/releaseDispatch.js +3 -2
  136. package/dist/tools/dispatch/validation.d.ts +7 -0
  137. package/dist/tools/dispatch/validation.js +71 -0
  138. package/dist/tools/{getPlan.js → goals/getPlan.js} +4 -4
  139. package/dist/tools/{goalSubgoalTask.d.ts → goals/goalSubgoalTask.d.ts} +1 -1
  140. package/dist/tools/goals/goalSubgoalTask.js +118 -0
  141. package/dist/tools/goals/index.d.ts +3 -0
  142. package/dist/tools/goals/index.js +3 -0
  143. package/dist/tools/{savePlan.js → goals/savePlan.js} +28 -8
  144. package/dist/tools/registry.d.ts +7 -11
  145. package/dist/tools/registry.js +17 -1178
  146. package/dist/tools/{checkReleaseGate.d.ts → release/checkReleaseGate.d.ts} +1 -1
  147. package/dist/tools/{checkReleaseGate.js → release/checkReleaseGate.js} +3 -3
  148. package/dist/tools/release/index.d.ts +2 -0
  149. package/dist/tools/release/index.js +2 -0
  150. package/dist/tools/{releaseMode.d.ts → release/releaseMode.d.ts} +2 -2
  151. package/dist/tools/{releaseMode.js → release/releaseMode.js} +46 -26
  152. package/dist/tools/tasks/cancelTask.d.ts +11 -0
  153. package/dist/tools/tasks/cancelTask.js +85 -0
  154. package/dist/tools/{createTask.d.ts → tasks/createTask.d.ts} +10 -2
  155. package/dist/tools/{createTask.js → tasks/createTask.js} +42 -27
  156. package/dist/tools/{diagnoseTask.d.ts → tasks/diagnoseTask.d.ts} +1 -1
  157. package/dist/tools/{diagnoseTask.js → tasks/diagnoseTask.js} +8 -8
  158. package/dist/tools/{evidencePack.js → tasks/evidencePack.js} +34 -22
  159. package/dist/tools/{getTaskFile.js → tasks/getTaskFile.js} +4 -4
  160. package/dist/tools/{getTaskStatus.d.ts → tasks/getTaskStatus.d.ts} +6 -1
  161. package/dist/tools/{getTaskStatus.js → tasks/getTaskStatus.js} +5 -5
  162. package/dist/tools/{getTaskStdoutTail.js → tasks/getTaskStdoutTail.js} +13 -11
  163. package/dist/tools/{getTaskSummary.js → tasks/getTaskSummary.js} +33 -26
  164. package/dist/tools/tasks/index.d.ts +18 -0
  165. package/dist/tools/tasks/index.js +18 -0
  166. package/dist/tools/tasks/killTask.d.ts +1 -0
  167. package/dist/tools/{listTasks.d.ts → tasks/listTasks.d.ts} +1 -1
  168. package/dist/tools/{listTasks.js → tasks/listTasks.js} +3 -3
  169. package/dist/tools/{reconcileTasks.d.ts → tasks/reconcileTasks.d.ts} +1 -1
  170. package/dist/tools/{reconcileTasks.js → tasks/reconcileTasks.js} +64 -66
  171. package/dist/tools/tasks/retryTask.js +60 -0
  172. package/dist/tools/{runTaskLoop.d.ts → tasks/runTaskLoop.d.ts} +6 -6
  173. package/dist/tools/{runTaskLoop.js → tasks/runTaskLoop.js} +58 -30
  174. package/dist/tools/tasks/runVerification.js +38 -0
  175. package/dist/tools/{taskLineage.js → tasks/taskLineage.js} +8 -7
  176. package/dist/tools/{taskOutputs.js → tasks/taskOutputs.js} +17 -16
  177. package/dist/tools/{androidDoctor.js → workspace/androidDoctor.js} +16 -10
  178. package/dist/tools/{applyPatch.d.ts → workspace/applyPatch.d.ts} +1 -1
  179. package/dist/tools/workspace/applyPatch.js +53 -0
  180. package/dist/tools/{discardWorktree.js → workspace/discardWorktree.js} +3 -3
  181. package/dist/tools/workspace/index.d.ts +10 -0
  182. package/dist/tools/workspace/index.js +10 -0
  183. package/dist/tools/{listAgents.d.ts → workspace/listAgents.d.ts} +5 -0
  184. package/dist/tools/{listAgents.js → workspace/listAgents.js} +8 -4
  185. package/dist/tools/{listWorkspace.js → workspace/listWorkspace.js} +3 -3
  186. package/dist/tools/{mergeWorktree.js → workspace/mergeWorktree.js} +3 -3
  187. package/dist/tools/{readWorkspaceFile.js → workspace/readWorkspaceFile.js} +8 -8
  188. package/dist/tools/{recommendAgentForTask.js → workspace/recommendAgentForTask.js} +3 -3
  189. package/dist/tools/{searchWorkspace.js → workspace/searchWorkspace.js} +5 -5
  190. package/dist/tools/{syncFile.d.ts → workspace/syncFile.d.ts} +1 -1
  191. package/dist/tools/workspace/syncFile.js +134 -0
  192. package/dist/utils/atomicFile.d.ts +7 -0
  193. package/dist/utils/atomicFile.js +48 -0
  194. package/dist/utils/boundedFile.d.ts +8 -0
  195. package/dist/utils/boundedFile.js +93 -0
  196. package/dist/utils/lockedJsonFile.d.ts +15 -0
  197. package/dist/utils/lockedJsonFile.js +224 -0
  198. package/dist/utils/platform.d.ts +11 -0
  199. package/dist/utils/platform.js +26 -0
  200. package/dist/utils/stableJson.d.ts +2 -0
  201. package/dist/utils/stableJson.js +12 -0
  202. package/dist/version.d.ts +3 -2
  203. package/dist/version.js +3 -2
  204. package/dist/watcherStatus.d.ts +2 -2
  205. package/dist/watcherStatus.js +18 -0
  206. package/docs/CODE_WIKI.md +808 -143
  207. package/docs/architecture.md +15 -55
  208. package/docs/assets/patchwarden-desktop-onboarding.png +0 -0
  209. package/docs/control-center/README.md +4 -4
  210. package/docs/dashboard-overview.md +4 -4
  211. package/docs/desktop-app.md +175 -0
  212. package/docs/evidence-pack-schema.md +3 -3
  213. package/docs/open-source-application.md +17 -11
  214. package/docs/release-checklist.md +1 -1
  215. package/docs/release-evidence.md +39 -29
  216. package/docs/task-safe-review-workflow.md +5 -5
  217. package/docs/threat-model.md +19 -1
  218. package/examples/config.example.json +4 -2
  219. package/package.json +27 -10
  220. package/scripts/README.md +15 -6
  221. package/scripts/build.js +11 -0
  222. package/scripts/checks/brand-check.js +3 -0
  223. package/scripts/checks/build-output-check.js +37 -0
  224. package/scripts/checks/control-center-smoke.js +91 -6
  225. package/scripts/checks/control-smoke.js +19 -8
  226. package/scripts/checks/http-mcp-smoke.js +82 -4
  227. package/scripts/checks/lifecycle-smoke.js +10 -10
  228. package/scripts/checks/mcp-manifest-check.js +1 -1
  229. package/scripts/checks/mcp-smoke.js +27 -3
  230. package/scripts/checks/package-manifest-check.js +20 -7
  231. package/scripts/checks/unit-tests.js +3 -5
  232. package/scripts/checks/watcher-supervisor-smoke.js +9 -0
  233. package/scripts/control/get-patchwarden-health.ps1 +1 -1
  234. package/scripts/control/manage-patchwarden.ps1 +69 -4
  235. package/scripts/control/provision-patchwarden-tunnel.ps1 +127 -0
  236. package/scripts/control/run-background-supervisor.ps1 +41 -0
  237. package/scripts/control/start-patchwarden-tunnel.ps1 +126 -32
  238. package/{PatchWarden-Control-Tray.cmd → scripts/launchers/PatchWarden-Control-Tray.cmd} +3 -3
  239. package/scripts/launchers/PatchWarden-Control.cmd +6 -0
  240. package/{PatchWarden-Desktop.cmd → scripts/launchers/PatchWarden-Desktop.cmd} +2 -2
  241. package/scripts/launchers/Restart-PatchWarden-Control.cmd +6 -0
  242. package/{Stop-PatchWarden.cmd → scripts/launchers/Stop-PatchWarden.cmd} +2 -2
  243. package/scripts/lib/clean-generated-output.js +11 -0
  244. package/scripts/lib/file-discovery.js +12 -0
  245. package/scripts/release/desktop-preflight.js +211 -0
  246. package/scripts/release/pack-clean.js +59 -14
  247. package/src/assessments/agentAssessor.ts +26 -12
  248. package/src/assessments/assessmentStore.ts +52 -40
  249. package/src/config.ts +70 -13
  250. package/src/control/fileManager.ts +40 -0
  251. package/src/control/middleware/auth.ts +22 -4
  252. package/src/control/middleware/static.ts +16 -3
  253. package/src/control/routeTable.ts +301 -0
  254. package/src/control/routes/audit.ts +14 -6
  255. package/src/control/routes/evidence.ts +1 -1
  256. package/src/control/routes/lineage.ts +1 -1
  257. package/src/control/routes/process.ts +341 -40
  258. package/src/control/routes/sessions.ts +88 -25
  259. package/src/control/routes/status.ts +97 -17
  260. package/src/control/routes/taskActions.ts +24 -58
  261. package/src/control/routes/tasks.ts +66 -58
  262. package/src/control/routes/workspace.ts +56 -25
  263. package/src/control/runtime.ts +69 -33
  264. package/src/control/server.ts +36 -342
  265. package/src/control/shared.ts +77 -11
  266. package/src/direct/directAudit.ts +17 -9
  267. package/src/direct/directGuards.ts +72 -44
  268. package/src/direct/directPatch.ts +95 -8
  269. package/src/direct/directSessionStore.ts +211 -55
  270. package/src/direct/directVerification.ts +9 -23
  271. package/src/doctor.ts +73 -59
  272. package/src/goal/acceptanceEngine.ts +1 -1
  273. package/src/goal/goalProgress.ts +17 -13
  274. package/src/goal/goalReport.ts +5 -13
  275. package/src/goal/goalStatus.ts +19 -3
  276. package/src/goal/goalStore.ts +241 -54
  277. package/src/goal/handoffExport.ts +6 -6
  278. package/src/goal/specKitImport.ts +12 -12
  279. package/src/goal/subgoalSync.ts +11 -8
  280. package/src/goal/worktreeManager.ts +139 -22
  281. package/src/httpServer.ts +113 -30
  282. package/src/logging.ts +10 -3
  283. package/src/policy/projectPolicy.ts +5 -2
  284. package/src/release/releaseGate.ts +74 -27
  285. package/src/runner/agentInvocation.ts +126 -4
  286. package/src/runner/changeCapture.ts +291 -57
  287. package/src/runner/postTaskCleanup.ts +41 -17
  288. package/src/runner/processSecurity.ts +433 -0
  289. package/src/runner/runTask.ts +138 -91
  290. package/src/runner/simpleProcess.ts +66 -31
  291. package/src/{taskProgress.ts → runner/taskProgress.ts} +3 -3
  292. package/src/{taskRuntime.ts → runner/taskRuntime.ts} +4 -3
  293. package/src/runner/taskStatusStore.ts +54 -0
  294. package/src/runner/watch.ts +281 -51
  295. package/src/security/contentRedaction.ts +2 -2
  296. package/src/security/discoveryTokenStore.ts +18 -4
  297. package/src/security/loopbackHost.ts +19 -0
  298. package/src/security/pathGuard.ts +35 -16
  299. package/src/security/planGuard.ts +2 -1
  300. package/src/security/runtimeGuard.ts +9 -6
  301. package/src/security/secretComparison.ts +13 -0
  302. package/src/security/sensitiveGuard.ts +27 -8
  303. package/src/security/toolInvocationGuard.ts +2 -2
  304. package/src/security/workspaceRootGuard.ts +53 -0
  305. package/src/smoke-test.ts +25 -24
  306. package/src/tools/catalog/index.ts +4 -0
  307. package/src/tools/{toolCatalog.ts → catalog/toolCatalog.ts} +3 -12
  308. package/src/tools/{toolRegistry.ts → catalog/toolRegistry.ts} +7 -16
  309. package/src/tools/definitions/toolDefs.ts +1264 -0
  310. package/src/tools/{auditSession.ts → diagnostics/auditSession.ts} +2 -2
  311. package/src/tools/{auditTask.ts → diagnostics/auditTask.ts} +99 -32
  312. package/src/tools/{healthCheck.ts → diagnostics/healthCheck.ts} +8 -8
  313. package/src/tools/diagnostics/index.ts +6 -0
  314. package/src/tools/{safeStatus.ts → diagnostics/safeStatus.ts} +7 -7
  315. package/src/tools/{safeViews.ts → diagnostics/safeViews.ts} +27 -17
  316. package/src/tools/{schemaDriftCheck.ts → diagnostics/schemaDriftCheck.ts} +3 -3
  317. package/src/tools/{createDirectSession.ts → direct/createDirectSession.ts} +6 -6
  318. package/src/tools/{finalizeDirectSession.ts → direct/finalizeDirectSession.ts} +7 -5
  319. package/src/tools/direct/index.ts +3 -0
  320. package/src/tools/{runDirectVerificationBundle.ts → direct/runDirectVerificationBundle.ts} +2 -2
  321. package/src/tools/{discoverTools.ts → discovery/discoverTools.ts} +6 -6
  322. package/src/tools/{explainTool.ts → discovery/explainTool.ts} +3 -3
  323. package/src/tools/discovery/index.ts +3 -0
  324. package/src/tools/{invokeDiscoveredTool.ts → discovery/invokeDiscoveredTool.ts} +7 -7
  325. package/src/tools/dispatch/coreDispatch.ts +36 -31
  326. package/src/tools/dispatch/diagnosticDispatch.ts +26 -21
  327. package/src/tools/dispatch/directDispatch.ts +11 -10
  328. package/src/tools/dispatch/goalDispatch.ts +5 -4
  329. package/src/tools/dispatch/releaseDispatch.ts +3 -2
  330. package/src/tools/dispatch/validation.ts +78 -0
  331. package/src/tools/{getPlan.ts → goals/getPlan.ts} +4 -4
  332. package/src/tools/{goalSubgoalTask.ts → goals/goalSubgoalTask.ts} +41 -26
  333. package/src/tools/goals/index.ts +3 -0
  334. package/src/tools/{savePlan.ts → goals/savePlan.ts} +38 -8
  335. package/src/tools/registry.ts +17 -1261
  336. package/src/tools/{checkReleaseGate.ts → release/checkReleaseGate.ts} +3 -3
  337. package/src/tools/release/index.ts +2 -0
  338. package/src/tools/{releaseMode.ts → release/releaseMode.ts} +42 -24
  339. package/src/tools/tasks/cancelTask.ts +109 -0
  340. package/src/tools/{createTask.ts → tasks/createTask.ts} +49 -26
  341. package/src/tools/{diagnoseTask.ts → tasks/diagnoseTask.ts} +8 -8
  342. package/src/tools/{evidencePack.ts → tasks/evidencePack.ts} +36 -22
  343. package/src/tools/{getTaskFile.ts → tasks/getTaskFile.ts} +4 -4
  344. package/src/tools/{getTaskStatus.ts → tasks/getTaskStatus.ts} +7 -5
  345. package/src/tools/{getTaskStdoutTail.ts → tasks/getTaskStdoutTail.ts} +13 -11
  346. package/src/tools/{getTaskSummary.ts → tasks/getTaskSummary.ts} +44 -33
  347. package/src/tools/tasks/index.ts +18 -0
  348. package/src/tools/{listTasks.ts → tasks/listTasks.ts} +4 -4
  349. package/src/tools/{reconcileTasks.ts → tasks/reconcileTasks.ts} +68 -66
  350. package/src/tools/tasks/retryTask.ts +69 -0
  351. package/src/tools/{runTaskLoop.ts → tasks/runTaskLoop.ts} +69 -34
  352. package/src/tools/{runVerification.ts → tasks/runVerification.ts} +6 -3
  353. package/src/tools/{taskLineage.ts → tasks/taskLineage.ts} +8 -7
  354. package/src/tools/{taskOutputs.ts → tasks/taskOutputs.ts} +27 -20
  355. package/src/tools/{androidDoctor.ts → workspace/androidDoctor.ts} +15 -10
  356. package/src/tools/{applyPatch.ts → workspace/applyPatch.ts} +38 -16
  357. package/src/tools/{discardWorktree.ts → workspace/discardWorktree.ts} +3 -3
  358. package/src/tools/workspace/index.ts +10 -0
  359. package/src/tools/{listAgents.ts → workspace/listAgents.ts} +11 -4
  360. package/src/tools/{listWorkspace.ts → workspace/listWorkspace.ts} +3 -3
  361. package/src/tools/{mergeWorktree.ts → workspace/mergeWorktree.ts} +3 -3
  362. package/src/tools/{readWorkspaceFile.ts → workspace/readWorkspaceFile.ts} +8 -8
  363. package/src/tools/{recommendAgentForTask.ts → workspace/recommendAgentForTask.ts} +3 -3
  364. package/src/tools/{searchWorkspace.ts → workspace/searchWorkspace.ts} +5 -5
  365. package/src/tools/workspace/syncFile.ts +292 -0
  366. package/src/utils/atomicFile.ts +58 -0
  367. package/src/utils/boundedFile.ts +116 -0
  368. package/src/utils/lockedJsonFile.ts +251 -0
  369. package/src/utils/platform.ts +31 -0
  370. package/src/utils/stableJson.ts +12 -0
  371. package/src/version.ts +3 -2
  372. package/src/watcherStatus.ts +19 -1
  373. package/ui/desktop-bootstrap.js +11 -0
  374. package/ui/desktop-bridge.js +36 -0
  375. package/ui/desktop.css +160 -0
  376. package/ui/getting-started.js +93 -0
  377. package/ui/i18n.js +484 -0
  378. package/ui/log-parser.js +30 -0
  379. package/ui/pages/audit.html +12 -0
  380. package/ui/pages/dashboard.html +90 -147
  381. package/ui/pages/direct-sessions.html +123 -26
  382. package/ui/pages/getting-started.html +43 -0
  383. package/ui/pages/logs.html +105 -22
  384. package/ui/pages/settings.html +101 -0
  385. package/ui/pages/task-detail.html +12 -0
  386. package/ui/pages/tasks.html +54 -8
  387. package/ui/pages/workspace.html +38 -1
  388. package/ui/settings.js +245 -0
  389. package/PatchWarden-Control.cmd +0 -6
  390. package/Restart-PatchWarden-Control.cmd +0 -6
  391. package/dist/test/unit/acceptance-engine.test.d.ts +0 -1
  392. package/dist/test/unit/acceptance-engine.test.js +0 -228
  393. package/dist/test/unit/agent-router.test.d.ts +0 -1
  394. package/dist/test/unit/agent-router.test.js +0 -287
  395. package/dist/test/unit/android-doctor.test.d.ts +0 -1
  396. package/dist/test/unit/android-doctor.test.js +0 -118
  397. package/dist/test/unit/apply-patch.test.d.ts +0 -1
  398. package/dist/test/unit/apply-patch.test.js +0 -225
  399. package/dist/test/unit/audit-checks.test.d.ts +0 -1
  400. package/dist/test/unit/audit-checks.test.js +0 -350
  401. package/dist/test/unit/chinese-path.test.d.ts +0 -1
  402. package/dist/test/unit/chinese-path.test.js +0 -91
  403. package/dist/test/unit/command-guard.test.d.ts +0 -1
  404. package/dist/test/unit/command-guard.test.js +0 -160
  405. package/dist/test/unit/create-task.test.d.ts +0 -1
  406. package/dist/test/unit/create-task.test.js +0 -197
  407. package/dist/test/unit/diagnose-task.test.d.ts +0 -1
  408. package/dist/test/unit/diagnose-task.test.js +0 -457
  409. package/dist/test/unit/direct-guards.test.d.ts +0 -1
  410. package/dist/test/unit/direct-guards.test.js +0 -328
  411. package/dist/test/unit/discovery-token-store.test.d.ts +0 -1
  412. package/dist/test/unit/discovery-token-store.test.js +0 -139
  413. package/dist/test/unit/evidence-pack.test.d.ts +0 -1
  414. package/dist/test/unit/evidence-pack.test.js +0 -224
  415. package/dist/test/unit/get-task-status.test.d.ts +0 -1
  416. package/dist/test/unit/get-task-status.test.js +0 -174
  417. package/dist/test/unit/get-task-summary.test.d.ts +0 -1
  418. package/dist/test/unit/get-task-summary.test.js +0 -146
  419. package/dist/test/unit/goal-graph.test.d.ts +0 -1
  420. package/dist/test/unit/goal-graph.test.js +0 -298
  421. package/dist/test/unit/goal-progress.test.d.ts +0 -1
  422. package/dist/test/unit/goal-progress.test.js +0 -381
  423. package/dist/test/unit/goal-report.test.d.ts +0 -1
  424. package/dist/test/unit/goal-report.test.js +0 -159
  425. package/dist/test/unit/goal-status.test.d.ts +0 -1
  426. package/dist/test/unit/goal-status.test.js +0 -215
  427. package/dist/test/unit/goal-store.test.d.ts +0 -1
  428. package/dist/test/unit/goal-store.test.js +0 -253
  429. package/dist/test/unit/goal-subgoal-task.test.d.ts +0 -1
  430. package/dist/test/unit/goal-subgoal-task.test.js +0 -55
  431. package/dist/test/unit/goal-tools-registry.test.d.ts +0 -1
  432. package/dist/test/unit/goal-tools-registry.test.js +0 -192
  433. package/dist/test/unit/handoff-export.test.d.ts +0 -1
  434. package/dist/test/unit/handoff-export.test.js +0 -263
  435. package/dist/test/unit/invoke-discovered-tool.test.d.ts +0 -1
  436. package/dist/test/unit/invoke-discovered-tool.test.js +0 -167
  437. package/dist/test/unit/logging.test.d.ts +0 -1
  438. package/dist/test/unit/logging.test.js +0 -397
  439. package/dist/test/unit/path-guard.test.d.ts +0 -1
  440. package/dist/test/unit/path-guard.test.js +0 -133
  441. package/dist/test/unit/post-task-cleanup.test.d.ts +0 -1
  442. package/dist/test/unit/post-task-cleanup.test.js +0 -48
  443. package/dist/test/unit/project-policy-release-mode.test.d.ts +0 -1
  444. package/dist/test/unit/project-policy-release-mode.test.js +0 -125
  445. package/dist/test/unit/reconcile-tasks.test.d.ts +0 -1
  446. package/dist/test/unit/reconcile-tasks.test.js +0 -456
  447. package/dist/test/unit/release-gate.test.d.ts +0 -1
  448. package/dist/test/unit/release-gate.test.js +0 -242
  449. package/dist/test/unit/run-task-loop.test.d.ts +0 -1
  450. package/dist/test/unit/run-task-loop.test.js +0 -380
  451. package/dist/test/unit/safe-status.test.d.ts +0 -1
  452. package/dist/test/unit/safe-status.test.js +0 -165
  453. package/dist/test/unit/safe-views.test.d.ts +0 -1
  454. package/dist/test/unit/safe-views.test.js +0 -171
  455. package/dist/test/unit/schema-drift-check.test.d.ts +0 -1
  456. package/dist/test/unit/schema-drift-check.test.js +0 -176
  457. package/dist/test/unit/sensitive-guard.test.d.ts +0 -1
  458. package/dist/test/unit/sensitive-guard.test.js +0 -104
  459. package/dist/test/unit/spec-kit-import.test.d.ts +0 -1
  460. package/dist/test/unit/spec-kit-import.test.js +0 -341
  461. package/dist/test/unit/subgoal-sync.test.d.ts +0 -1
  462. package/dist/test/unit/subgoal-sync.test.js +0 -183
  463. package/dist/test/unit/sync-file.test.d.ts +0 -1
  464. package/dist/test/unit/sync-file.test.js +0 -154
  465. package/dist/test/unit/tool-invocation-guard.test.d.ts +0 -1
  466. package/dist/test/unit/tool-invocation-guard.test.js +0 -432
  467. package/dist/test/unit/tool-usage-stats.test.d.ts +0 -1
  468. package/dist/test/unit/tool-usage-stats.test.js +0 -300
  469. package/dist/test/unit/toolSearch.test.d.ts +0 -1
  470. package/dist/test/unit/toolSearch.test.js +0 -571
  471. package/dist/test/unit/wait-for-task.test.d.ts +0 -1
  472. package/dist/test/unit/wait-for-task.test.js +0 -144
  473. package/dist/test/unit/watcher-status.test.d.ts +0 -1
  474. package/dist/test/unit/watcher-status.test.js +0 -169
  475. package/dist/test/unit/worktree-manager.test.d.ts +0 -1
  476. package/dist/test/unit/worktree-manager.test.js +0 -176
  477. package/dist/tools/applyPatch.js +0 -41
  478. package/dist/tools/cancelTask.d.ts +0 -30
  479. package/dist/tools/cancelTask.js +0 -64
  480. package/dist/tools/finalizeDirectSession.js +0 -84
  481. package/dist/tools/goalSubgoalTask.js +0 -110
  482. package/dist/tools/killTask.d.ts +0 -15
  483. package/dist/tools/retryTask.js +0 -45
  484. package/dist/tools/runVerification.js +0 -36
  485. package/dist/tools/syncFile.js +0 -65
  486. package/docs/control-center/control-center-daily-driver.md +0 -211
  487. package/docs/control-center/control-center-mvp.md +0 -205
  488. package/docs/control-center/control-center-phase2.md +0 -159
  489. package/docs/execution-plan-2026-07-09.md +0 -315
  490. package/docs/release-v0.6.0.md +0 -71
  491. package/docs/release-v0.6.1.md +0 -75
  492. package/docs/release-v0.6.4.md +0 -45
  493. package/docs/roadmap-execution-and-acceptance.md +0 -365
  494. package/src/test/unit/acceptance-engine.test.ts +0 -261
  495. package/src/test/unit/agent-router.test.ts +0 -342
  496. package/src/test/unit/android-doctor.test.ts +0 -158
  497. package/src/test/unit/apply-patch.test.ts +0 -293
  498. package/src/test/unit/audit-checks.test.ts +0 -567
  499. package/src/test/unit/chinese-path.test.ts +0 -106
  500. package/src/test/unit/command-guard.test.ts +0 -221
  501. package/src/test/unit/create-task.test.ts +0 -255
  502. package/src/test/unit/diagnose-task.test.ts +0 -544
  503. package/src/test/unit/direct-guards.test.ts +0 -467
  504. package/src/test/unit/discovery-token-store.test.ts +0 -181
  505. package/src/test/unit/evidence-pack.test.ts +0 -251
  506. package/src/test/unit/get-task-status.test.ts +0 -203
  507. package/src/test/unit/get-task-summary.test.ts +0 -173
  508. package/src/test/unit/goal-graph.test.ts +0 -347
  509. package/src/test/unit/goal-progress.test.ts +0 -538
  510. package/src/test/unit/goal-report.test.ts +0 -189
  511. package/src/test/unit/goal-status.test.ts +0 -270
  512. package/src/test/unit/goal-store.test.ts +0 -318
  513. package/src/test/unit/goal-subgoal-task.test.ts +0 -72
  514. package/src/test/unit/goal-tools-registry.test.ts +0 -245
  515. package/src/test/unit/handoff-export.test.ts +0 -295
  516. package/src/test/unit/invoke-discovered-tool.test.ts +0 -216
  517. package/src/test/unit/logging.test.ts +0 -497
  518. package/src/test/unit/path-guard.test.ts +0 -180
  519. package/src/test/unit/post-task-cleanup.test.ts +0 -53
  520. package/src/test/unit/project-policy-release-mode.test.ts +0 -156
  521. package/src/test/unit/reconcile-tasks.test.ts +0 -551
  522. package/src/test/unit/release-gate.test.ts +0 -314
  523. package/src/test/unit/run-task-loop.test.ts +0 -425
  524. package/src/test/unit/safe-status.test.ts +0 -187
  525. package/src/test/unit/safe-views.test.ts +0 -184
  526. package/src/test/unit/schema-drift-check.test.ts +0 -260
  527. package/src/test/unit/sensitive-guard.test.ts +0 -124
  528. package/src/test/unit/spec-kit-import.test.ts +0 -429
  529. package/src/test/unit/subgoal-sync.test.ts +0 -236
  530. package/src/test/unit/sync-file.test.ts +0 -231
  531. package/src/test/unit/tool-invocation-guard.test.ts +0 -542
  532. package/src/test/unit/tool-usage-stats.test.ts +0 -384
  533. package/src/test/unit/toolSearch.test.ts +0 -756
  534. package/src/test/unit/wait-for-task.test.ts +0 -176
  535. package/src/test/unit/watcher-status.test.ts +0 -190
  536. package/src/test/unit/worktree-manager.test.ts +0 -247
  537. package/src/tools/cancelTask.ts +0 -70
  538. package/src/tools/retryTask.ts +0 -53
  539. package/src/tools/syncFile.ts +0 -122
  540. /package/dist/tools/{toolCatalog.d.ts → catalog/toolCatalog.d.ts} +0 -0
  541. /package/dist/tools/{toolSearch.d.ts → catalog/toolSearch.d.ts} +0 -0
  542. /package/dist/tools/{toolSearch.js → catalog/toolSearch.js} +0 -0
  543. /package/dist/tools/{toolUsageStats.d.ts → catalog/toolUsageStats.d.ts} +0 -0
  544. /package/dist/tools/{toolUsageStats.js → catalog/toolUsageStats.js} +0 -0
  545. /package/dist/tools/{createDirectSession.d.ts → direct/createDirectSession.d.ts} +0 -0
  546. /package/dist/tools/{runDirectVerificationBundle.d.ts → direct/runDirectVerificationBundle.d.ts} +0 -0
  547. /package/dist/tools/{getPlan.d.ts → goals/getPlan.d.ts} +0 -0
  548. /package/dist/tools/{savePlan.d.ts → goals/savePlan.d.ts} +0 -0
  549. /package/dist/tools/{evidencePack.d.ts → tasks/evidencePack.d.ts} +0 -0
  550. /package/dist/tools/{getTaskFile.d.ts → tasks/getTaskFile.d.ts} +0 -0
  551. /package/dist/tools/{getTaskProgress.d.ts → tasks/getTaskProgress.d.ts} +0 -0
  552. /package/dist/tools/{getTaskProgress.js → tasks/getTaskProgress.js} +0 -0
  553. /package/dist/tools/{getTaskStdoutTail.d.ts → tasks/getTaskStdoutTail.d.ts} +0 -0
  554. /package/dist/tools/{getTaskSummary.d.ts → tasks/getTaskSummary.d.ts} +0 -0
  555. /package/dist/tools/{killTask.js → tasks/killTask.js} +0 -0
  556. /package/dist/tools/{retryTask.d.ts → tasks/retryTask.d.ts} +0 -0
  557. /package/dist/tools/{runVerification.d.ts → tasks/runVerification.d.ts} +0 -0
  558. /package/dist/tools/{taskLineage.d.ts → tasks/taskLineage.d.ts} +0 -0
  559. /package/dist/tools/{taskOutputs.d.ts → tasks/taskOutputs.d.ts} +0 -0
  560. /package/dist/tools/{waitForTask.d.ts → tasks/waitForTask.d.ts} +0 -0
  561. /package/dist/tools/{waitForTask.js → tasks/waitForTask.js} +0 -0
  562. /package/dist/tools/{androidDoctor.d.ts → workspace/androidDoctor.d.ts} +0 -0
  563. /package/dist/tools/{discardWorktree.d.ts → workspace/discardWorktree.d.ts} +0 -0
  564. /package/dist/tools/{listWorkspace.d.ts → workspace/listWorkspace.d.ts} +0 -0
  565. /package/dist/tools/{mergeWorktree.d.ts → workspace/mergeWorktree.d.ts} +0 -0
  566. /package/dist/tools/{readWorkspaceFile.d.ts → workspace/readWorkspaceFile.d.ts} +0 -0
  567. /package/dist/tools/{recommendAgentForTask.d.ts → workspace/recommendAgentForTask.d.ts} +0 -0
  568. /package/dist/tools/{searchWorkspace.d.ts → workspace/searchWorkspace.d.ts} +0 -0
  569. /package/src/tools/{toolSearch.ts → catalog/toolSearch.ts} +0 -0
  570. /package/src/tools/{toolUsageStats.ts → catalog/toolUsageStats.ts} +0 -0
  571. /package/src/tools/{getTaskProgress.ts → tasks/getTaskProgress.ts} +0 -0
  572. /package/src/tools/{killTask.ts → tasks/killTask.ts} +0 -0
  573. /package/src/tools/{waitForTask.ts → tasks/waitForTask.ts} +0 -0
@@ -1,20 +1,30 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { createWriteStream, writeFileSync, readFileSync, existsSync } from "node:fs";
2
+ import { allowedEnvironmentValues, buildChildEnvironment, prepareShellFreeCommand, redactProcessOutput, resolveTrustedExecutable, SecureProcessLogCapture, } from "./processSecurity.js";
3
3
  const GRACEFUL_KILL_MS = 2000;
4
4
  const DEFAULT_MAX_STDOUT = 524288;
5
5
  const DEFAULT_MAX_STDERR = 131072;
6
6
  export function runSimpleProcessSync(options) {
7
7
  const maxStdout = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT;
8
8
  const maxStderr = options.maxStderrBytes ?? DEFAULT_MAX_STDERR;
9
+ const exactRedactionValues = allowedEnvironmentValues(options.environmentVariableNames);
10
+ const logCapture = new SecureProcessLogCapture([options.stdoutPath, options.stderrPath], options.maxLogBytes);
9
11
  let result;
10
12
  try {
11
- result = spawnSync(options.command, options.args, {
13
+ const env = buildChildEnvironment({
14
+ cwd: options.cwd,
15
+ allowedNames: options.environmentVariableNames,
16
+ blockedNames: options.blockedEnvironmentVariableNames,
17
+ });
18
+ const prepared = prepareShellFreeCommand(options.command, options.args, options.cwd, { pathValue: env.PATH });
19
+ const command = resolveTrustedExecutable(prepared.command, options.cwd, { pathValue: env.PATH });
20
+ result = spawnSync(command, prepared.args, {
12
21
  cwd: options.cwd,
13
22
  stdio: ["ignore", "pipe", "pipe"],
14
23
  timeout: options.timeoutMs,
15
24
  windowsHide: true,
16
25
  maxBuffer: maxStdout + 1024,
17
26
  encoding: "utf-8",
27
+ env,
18
28
  });
19
29
  }
20
30
  catch (error) {
@@ -28,23 +38,16 @@ export function runSimpleProcessSync(options) {
28
38
  stderrTruncated: false,
29
39
  };
30
40
  }
31
- const stdout = result.stdout || "";
32
- const stderr = result.stderr || "";
41
+ const rawStdout = result.stdout || "";
42
+ const rawStderr = result.stderr || "";
43
+ const stdout = redactProcessOutput(rawStdout, exactRedactionValues);
44
+ const stderr = redactProcessOutput(rawStderr, exactRedactionValues);
33
45
  const timedOut = Boolean(result.signal === "SIGTERM" || result.signal === "SIGKILL" ||
34
46
  (result.signal !== null && result.status === null) ||
35
- (result.error && result.error.code === "ETIMEDOUT"));
36
- if (options.stdoutPath && stdout) {
37
- try {
38
- writeFileSyncAppend(options.stdoutPath, stdout);
39
- }
40
- catch { } // best-effort log write; failure doesn't affect result
41
- }
42
- if (options.stderrPath && stderr) {
43
- try {
44
- writeFileSyncAppend(options.stderrPath, stderr);
45
- }
46
- catch { } // best-effort log write; failure doesn't affect result
47
- }
47
+ (result.error && errorCode(result.error) === "ETIMEDOUT"));
48
+ logCapture.append(options.stdoutPath, rawStdout);
49
+ logCapture.append(options.stderrPath, rawStderr);
50
+ logCapture.flush(exactRedactionValues);
48
51
  return {
49
52
  exitCode: result.status,
50
53
  stdout: stdout.length > maxStdout ? stdout.slice(0, maxStdout) : stdout,
@@ -52,21 +55,30 @@ export function runSimpleProcessSync(options) {
52
55
  // If timed out, don't report spawnError — it's a timeout, not a spawn failure
53
56
  spawnError: timedOut ? null : (result.error ? result.error.message : null),
54
57
  timedOut,
55
- stdoutTruncated: stdout.length > maxStdout,
56
- stderrTruncated: stderr.length > maxStderr,
58
+ stdoutTruncated: rawStdout.length > maxStdout || stdout.length > maxStdout,
59
+ stderrTruncated: rawStderr.length > maxStderr || stderr.length > maxStderr,
57
60
  };
58
61
  }
59
62
  export async function runSimpleProcess(options) {
60
63
  const maxStdout = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT;
61
64
  const maxStderr = options.maxStderrBytes ?? DEFAULT_MAX_STDERR;
62
- const deadlineMs = Date.now() + options.timeoutMs;
65
+ const exactRedactionValues = allowedEnvironmentValues(options.environmentVariableNames);
66
+ const logCapture = new SecureProcessLogCapture([options.stdoutPath, options.stderrPath], options.maxLogBytes);
63
67
  let child;
64
68
  try {
65
- child = spawn(options.command, options.args, {
69
+ const env = buildChildEnvironment({
70
+ cwd: options.cwd,
71
+ allowedNames: options.environmentVariableNames,
72
+ blockedNames: options.blockedEnvironmentVariableNames,
73
+ });
74
+ const prepared = prepareShellFreeCommand(options.command, options.args, options.cwd, { pathValue: env.PATH });
75
+ const command = resolveTrustedExecutable(prepared.command, options.cwd, { pathValue: env.PATH });
76
+ child = spawn(command, prepared.args, {
66
77
  cwd: options.cwd,
67
78
  stdio: ["ignore", "pipe", "pipe"],
68
79
  detached: process.platform !== "win32",
69
80
  windowsHide: true,
81
+ env,
70
82
  });
71
83
  }
72
84
  catch (error) {
@@ -80,8 +92,6 @@ export async function runSimpleProcess(options) {
80
92
  stderrTruncated: false,
81
93
  };
82
94
  }
83
- const stdoutStream = options.stdoutPath ? createWriteStream(options.stdoutPath, { flags: "a" }) : null;
84
- const stderrStream = options.stderrPath ? createWriteStream(options.stderrPath, { flags: "a" }) : null;
85
95
  let stdoutBuf = Buffer.alloc(0);
86
96
  let stderrBuf = Buffer.alloc(0);
87
97
  let stdoutTruncated = false;
@@ -105,7 +115,7 @@ export async function runSimpleProcess(options) {
105
115
  }
106
116
  };
107
117
  child.stdout?.on("data", (chunk) => {
108
- stdoutStream?.write(chunk);
118
+ logCapture.append(options.stdoutPath, chunk);
109
119
  if (stdoutBuf.length < maxStdout) {
110
120
  const remaining = maxStdout - stdoutBuf.length;
111
121
  if (chunk.length <= remaining) {
@@ -121,7 +131,7 @@ export async function runSimpleProcess(options) {
121
131
  }
122
132
  });
123
133
  child.stderr?.on("data", (chunk) => {
124
- stderrStream?.write(chunk);
134
+ logCapture.append(options.stderrPath, chunk);
125
135
  if (stderrBuf.length < maxStderr) {
126
136
  const remaining = maxStderr - stderrBuf.length;
127
137
  if (chunk.length <= remaining) {
@@ -146,7 +156,6 @@ export async function runSimpleProcess(options) {
146
156
  resolveExit(code);
147
157
  };
148
158
  child.once("close", (code) => finish(code));
149
- child.once("exit", (code) => finish(code));
150
159
  child.once("error", (error) => {
151
160
  spawnError = error.message;
152
161
  finish(null);
@@ -155,16 +164,17 @@ export async function runSimpleProcess(options) {
155
164
  clearTimeout(timeoutTimer);
156
165
  if (forceTimer)
157
166
  clearTimeout(forceTimer);
158
- stdoutStream?.destroy();
159
- stderrStream?.destroy();
167
+ logCapture.flush(exactRedactionValues);
168
+ const stdout = redactProcessOutput(stdoutBuf.toString("utf-8"), exactRedactionValues);
169
+ const stderr = redactProcessOutput(stderrBuf.toString("utf-8"), exactRedactionValues);
160
170
  return {
161
171
  exitCode,
162
- stdout: stdoutBuf.toString("utf-8"),
163
- stderr: stderrBuf.toString("utf-8"),
172
+ stdout: stdout.length > maxStdout ? stdout.slice(0, maxStdout) : stdout,
173
+ stderr: stderr.length > maxStderr ? stderr.slice(0, maxStderr) : stderr,
164
174
  spawnError,
165
175
  timedOut,
166
- stdoutTruncated,
167
- stderrTruncated,
176
+ stdoutTruncated: stdoutTruncated || stdout.length > maxStdout,
177
+ stderrTruncated: stderrTruncated || stderr.length > maxStderr,
168
178
  };
169
179
  }
170
180
  function gracefulKill(child) {
@@ -181,10 +191,13 @@ function forceKill(child) {
181
191
  return;
182
192
  try {
183
193
  if (process.platform === "win32") {
184
- const result = spawnSync("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
194
+ const systemRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows";
195
+ const taskkill = resolveTrustedExecutable(`${systemRoot}\\System32\\taskkill.exe`, process.cwd());
196
+ const result = spawnSync(taskkill, ["/PID", String(child.pid), "/T", "/F"], {
185
197
  stdio: "ignore",
186
198
  timeout: 5000,
187
199
  windowsHide: true,
200
+ env: buildChildEnvironment({ cwd: process.cwd() }),
188
201
  });
189
202
  if (result.status !== 0)
190
203
  child.kill("SIGKILL");
@@ -200,7 +213,8 @@ function forceKill(child) {
200
213
  catch { } // cleanup failure is safe to ignore
201
214
  }
202
215
  }
203
- function writeFileSyncAppend(path, content) {
204
- const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
205
- writeFileSync(path, existing + content, "utf-8");
216
+ function errorCode(error) {
217
+ return error && typeof error === "object" && "code" in error
218
+ ? String(error.code || "")
219
+ : null;
206
220
  }
@@ -1,4 +1,4 @@
1
- import type { TaskPhase } from "./tools/createTask.js";
1
+ import type { TaskPhase } from "../tools/tasks/createTask.js";
2
2
  export declare function writeTaskProgress(taskDir: string, phase: TaskPhase, options?: {
3
3
  currentCommand?: string | null;
4
4
  note?: string;
@@ -1,5 +1,5 @@
1
- import { writeFileSync } from "node:fs";
2
1
  import { join } from "node:path";
2
+ import { atomicWriteFileSync } from "../utils/atomicFile.js";
3
3
  const PHASES = [
4
4
  { phase: "queued", label: "Queued" },
5
5
  { phase: "preparing", label: "Preparing repository snapshot" },
@@ -32,5 +32,5 @@ export function writeTaskProgress(taskDir, phase, options) {
32
32
  lines.push(`Current command: ${options.currentCommand}`);
33
33
  if (options?.note)
34
34
  lines.push("", `Note: ${options.note}`);
35
- writeFileSync(join(taskDir, "progress.md"), `${lines.join("\n")}\n`, "utf-8");
35
+ atomicWriteFileSync(join(taskDir, "progress.md"), `${lines.join("\n")}\n`);
36
36
  }
@@ -1,4 +1,4 @@
1
- import type { TaskPhase } from "./tools/createTask.js";
1
+ import type { TaskPhase } from "../tools/tasks/createTask.js";
2
2
  export interface TaskRuntimeData {
3
3
  phase: TaskPhase;
4
4
  last_heartbeat_at: string;
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { atomicWriteJsonFileSync } from "../utils/atomicFile.js";
3
4
  export function readTaskRuntime(taskDir) {
4
5
  const runtimeFile = join(taskDir, "runtime.json");
5
6
  if (!existsSync(runtimeFile))
@@ -20,6 +21,6 @@ export function writeTaskRuntime(taskDir, patch) {
20
21
  ...current,
21
22
  ...patch,
22
23
  };
23
- writeFileSync(join(taskDir, "runtime.json"), JSON.stringify(next, null, 2), "utf-8");
24
+ atomicWriteJsonFileSync(join(taskDir, "runtime.json"), next);
24
25
  return next;
25
26
  }
@@ -0,0 +1,11 @@
1
+ import { type LockedJsonMutation } from "../utils/lockedJsonFile.js";
2
+ export type TaskStatusRecord = Record<string, unknown>;
3
+ export type TaskStatusMutation<T> = LockedJsonMutation<TaskStatusRecord, T>;
4
+ export interface TaskClaimResult {
5
+ claimed: boolean;
6
+ status: TaskStatusRecord;
7
+ }
8
+ export declare function readTaskStatusFile(statusFile: string): TaskStatusRecord;
9
+ export declare function mutateTaskStatus<T>(statusFile: string, mutation: (current: TaskStatusRecord) => TaskStatusMutation<T>): T;
10
+ export declare function claimPendingTask(statusFile: string, patch: TaskStatusRecord): TaskClaimResult;
11
+ export declare function updateTaskStatusFile(statusFile: string, patch: TaskStatusRecord): TaskStatusRecord;
@@ -0,0 +1,24 @@
1
+ import { PatchWardenError } from "../errors.js";
2
+ import { mutateLockedJsonFileSync, readJsonObjectFileSync, } from "../utils/lockedJsonFile.js";
3
+ export function readTaskStatusFile(statusFile) {
4
+ return readJsonObjectFileSync(statusFile);
5
+ }
6
+ export function mutateTaskStatus(statusFile, mutation) {
7
+ return mutateLockedJsonFileSync(statusFile, mutation, {
8
+ busyError: () => new PatchWardenError("task_status_busy", "Task status is currently being updated by another PatchWarden process.", "Retry the operation after the current task status update completes.", true),
9
+ });
10
+ }
11
+ export function claimPendingTask(statusFile, patch) {
12
+ return mutateTaskStatus(statusFile, (current) => {
13
+ if (current.status !== "pending")
14
+ return { result: { claimed: false, status: current } };
15
+ const next = { ...current, ...patch, updated_at: new Date().toISOString() };
16
+ return { next, result: { claimed: true, status: next } };
17
+ });
18
+ }
19
+ export function updateTaskStatusFile(statusFile, patch) {
20
+ return mutateTaskStatus(statusFile, (current) => {
21
+ const next = { ...current, ...patch, updated_at: new Date().toISOString() };
22
+ return { next, result: next };
23
+ });
24
+ }
@@ -16,4 +16,17 @@
16
16
  * Run: node dist/runner/watch.js
17
17
  * or: npm run watch
18
18
  */
19
- export {};
19
+ export declare class WatcherAlreadyRunningError extends Error {
20
+ constructor(message: string);
21
+ }
22
+ /**
23
+ * Acquire the watcher lock at `lockFilePath`. If a lock file exists and its
24
+ * recorded PID is still alive (per `process.kill(pid, 0)`), throws
25
+ * {@link WatcherAlreadyRunningError}. Otherwise overwrites the lock atomically.
26
+ *
27
+ * Exported for unit testing; `lockFilePath` defaults to the module-level
28
+ * {@link WATCHER_LOCK_FILE} but tests pass a temp path.
29
+ */
30
+ export declare function acquireWatcherLock(lockFilePath?: string): void;
31
+ export declare function releaseWatcherLock(lockFilePath?: string, instanceId?: string, pid?: number): boolean;
32
+ export declare function createNonOverlappingRunner(operation: () => Promise<void>, onSkipped?: () => void): () => Promise<boolean>;
@@ -16,15 +16,21 @@
16
16
  * Run: node dist/runner/watch.js
17
17
  * or: npm run watch
18
18
  */
19
- import { readdirSync, readFileSync, existsSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
19
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from "node:fs";
20
20
  import { resolve, join, dirname } from "node:path";
21
+ import { pathToFileURL } from "node:url";
21
22
  import { loadConfig, getConfig, getTasksDir, resolveWorkspaceRoot } from "../config.js";
22
23
  import { guardWorkspacePath } from "../security/pathGuard.js";
23
24
  import { guardAgentCommand, guardTestCommand } from "../security/commandGuard.js";
24
25
  import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
25
26
  import { captureRepoSnapshot } from "./changeCapture.js";
26
- import { runTask } from "./runTask.js";
27
+ import { mutateTaskStatus } from "./taskStatusStore.js";
27
28
  import { logger } from "../logging.js";
29
+ import { redactSensitiveContent } from "../security/contentRedaction.js";
30
+ import { atomicWriteFileSync } from "../utils/atomicFile.js";
31
+ // Note: `runTask` is imported lazily inside `tick()` via `await import("./runTask.js")`.
32
+ // This keeps `import { acquireWatcherLock }` side-effect-free for unit tests and
33
+ // avoids eagerly pulling in the full tools/dispatch graph at module load.
28
34
  // ── Bootstrap ─────────────────────────────────────────────────────
29
35
  loadConfig();
30
36
  const config = getConfig();
@@ -32,34 +38,160 @@ const tasksDir = getTasksDir(config);
32
38
  const wsRoot = resolveWorkspaceRoot(config);
33
39
  const POLL_INTERVAL_MS = 4000;
34
40
  const WATCHER_HEARTBEAT_FILE = join(dirname(tasksDir), "watcher-heartbeat.json");
41
+ const WATCHER_LOCK_FILE = join(dirname(tasksDir), "watcher.lock");
35
42
  const WATCHER_STARTED_AT = new Date().toISOString();
36
43
  const WATCHER_INSTANCE_ID = process.env.PATCHWARDEN_WATCHER_INSTANCE_ID || `standalone-${process.pid}-${Date.now()}`;
37
44
  const WATCHER_LAUNCHER_PID = Number.isInteger(Number(process.env.PATCHWARDEN_WATCHER_LAUNCHER_PID))
38
45
  ? Number(process.env.PATCHWARDEN_WATCHER_LAUNCHER_PID)
39
46
  : null;
40
- mkdirSync(dirname(WATCHER_HEARTBEAT_FILE), { recursive: true });
41
- logger.info(`[watcher] Workspace: ${wsRoot}`);
42
- logger.info(`[watcher] Tasks: ${tasksDir}`);
43
- logger.info(`[watcher] Polling every ${POLL_INTERVAL_MS / 1000}s`);
44
- logger.info(`[watcher] Press Ctrl+C to stop`);
45
47
  // Track executed tasks to prevent re-execution
46
48
  const executedTasks = new Set();
49
+ let consecutiveTickFailures = 0;
50
+ // ── Single-instance lock ─────────────────────────────────────────
51
+ // watch.ts is a long-running poller; without a lock, two watchers started
52
+ // against the same workspace would race on the same pending tasks. The lock
53
+ // is best-effort: it detects a live PID via process.kill(pid, 0) and refuses
54
+ // to start when one is found. A dead PID (crashed watcher) is taken over.
55
+ export class WatcherAlreadyRunningError extends Error {
56
+ constructor(message) {
57
+ super(message);
58
+ this.name = "WatcherAlreadyRunningError";
59
+ }
60
+ }
61
+ function errorCode(error) {
62
+ return error && typeof error === "object" && "code" in error
63
+ ? String(error.code || "")
64
+ : null;
65
+ }
66
+ function readWatcherLock(lockFilePath) {
67
+ try {
68
+ const parsed = JSON.parse(readFileSync(lockFilePath, "utf-8"));
69
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
70
+ ? parsed
71
+ : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function processIsAlive(pid) {
78
+ try {
79
+ process.kill(pid, 0);
80
+ return true;
81
+ }
82
+ catch (error) {
83
+ // EPERM means the process exists but the current user cannot signal it.
84
+ return errorCode(error) === "EPERM";
85
+ }
86
+ }
87
+ function runningWatcherError(lockData) {
88
+ return new WatcherAlreadyRunningError(`[watcher] Already running (pid=${lockData?.pid ?? "unknown"}, instance=${lockData?.instance_id ?? "unknown"}). Exiting.`);
89
+ }
90
+ function createWatcherLockExclusive(lockFilePath, content) {
91
+ const descriptor = openSync(lockFilePath, "wx", 0o600);
92
+ let writeError = null;
93
+ try {
94
+ writeFileSync(descriptor, content, "utf-8");
95
+ }
96
+ catch (error) {
97
+ writeError = error;
98
+ }
99
+ finally {
100
+ closeSync(descriptor);
101
+ }
102
+ if (writeError) {
103
+ try {
104
+ unlinkSync(lockFilePath);
105
+ }
106
+ catch { /* best effort after a partial write */ }
107
+ throw writeError;
108
+ }
109
+ }
110
+ /**
111
+ * Acquire the watcher lock at `lockFilePath`. If a lock file exists and its
112
+ * recorded PID is still alive (per `process.kill(pid, 0)`), throws
113
+ * {@link WatcherAlreadyRunningError}. Otherwise overwrites the lock atomically.
114
+ *
115
+ * Exported for unit testing; `lockFilePath` defaults to the module-level
116
+ * {@link WATCHER_LOCK_FILE} but tests pass a temp path.
117
+ */
118
+ export function acquireWatcherLock(lockFilePath = WATCHER_LOCK_FILE) {
119
+ const lockContent = JSON.stringify({
120
+ pid: process.pid,
121
+ instance_id: WATCHER_INSTANCE_ID,
122
+ started_at: WATCHER_STARTED_AT,
123
+ launcher_pid: WATCHER_LAUNCHER_PID,
124
+ }, null, 2);
125
+ try {
126
+ createWatcherLockExclusive(lockFilePath, lockContent);
127
+ return;
128
+ }
129
+ catch (error) {
130
+ if (errorCode(error) !== "EEXIST")
131
+ throw error;
132
+ }
133
+ const existing = readWatcherLock(lockFilePath);
134
+ if (typeof existing?.pid === "number" && processIsAlive(existing.pid)) {
135
+ throw runningWatcherError(existing);
136
+ }
137
+ try {
138
+ unlinkSync(lockFilePath);
139
+ }
140
+ catch (error) {
141
+ if (errorCode(error) !== "ENOENT")
142
+ throw error;
143
+ }
144
+ try {
145
+ createWatcherLockExclusive(lockFilePath, lockContent);
146
+ }
147
+ catch (error) {
148
+ if (errorCode(error) === "EEXIST") {
149
+ throw runningWatcherError(readWatcherLock(lockFilePath));
150
+ }
151
+ throw error;
152
+ }
153
+ }
154
+ export function releaseWatcherLock(lockFilePath = WATCHER_LOCK_FILE, instanceId = WATCHER_INSTANCE_ID, pid = process.pid) {
155
+ const existing = readWatcherLock(lockFilePath);
156
+ if (existing?.pid !== pid || existing.instance_id !== instanceId)
157
+ return false;
158
+ try {
159
+ unlinkSync(lockFilePath);
160
+ return true;
161
+ }
162
+ catch (error) {
163
+ if (errorCode(error) === "ENOENT")
164
+ return false;
165
+ throw error;
166
+ }
167
+ }
168
+ export function createNonOverlappingRunner(operation, onSkipped = () => undefined) {
169
+ let running = false;
170
+ return async () => {
171
+ if (running) {
172
+ onSkipped();
173
+ return false;
174
+ }
175
+ running = true;
176
+ try {
177
+ await operation();
178
+ return true;
179
+ }
180
+ finally {
181
+ running = false;
182
+ }
183
+ };
184
+ }
47
185
  // ── Main loop ─────────────────────────────────────────────────────
48
186
  async function tick() {
49
187
  try {
50
- const heartbeatTemporary = `${WATCHER_HEARTBEAT_FILE}.${WATCHER_INSTANCE_ID}.tmp`;
51
- writeFileSync(heartbeatTemporary, JSON.stringify({
52
- status: "running",
53
- pid: process.pid,
54
- started_at: WATCHER_STARTED_AT,
55
- last_heartbeat_at: new Date().toISOString(),
56
- instance_id: WATCHER_INSTANCE_ID,
57
- launcher_pid: WATCHER_LAUNCHER_PID,
58
- }, null, 2), "utf-8");
59
- renameSync(heartbeatTemporary, WATCHER_HEARTBEAT_FILE);
188
+ writeWatcherHeartbeat("running");
60
189
  // Ensure tasks directory exists
61
- if (!existsSync(tasksDir))
190
+ if (!existsSync(tasksDir)) {
191
+ consecutiveTickFailures = 0;
192
+ writeWatcherHeartbeat("running");
62
193
  return;
194
+ }
63
195
  const entries = readdirSync(tasksDir, { withFileTypes: true });
64
196
  const taskDirs = entries.filter((e) => e.isDirectory());
65
197
  for (const entry of taskDirs) {
@@ -73,21 +205,34 @@ async function tick() {
73
205
  continue;
74
206
  let statusData;
75
207
  try {
76
- statusData = JSON.parse(readFileSync(statusFile, "utf-8"));
208
+ const parsed = JSON.parse(readFileSync(statusFile, "utf-8"));
209
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
210
+ throw new Error("status.json must contain an object");
211
+ }
212
+ statusData = parsed;
77
213
  }
78
- catch {
79
- continue; // corrupted status, skip
214
+ catch (error) {
215
+ logger.warn("[watcher] Skipping unreadable task status", {
216
+ task_id: taskId,
217
+ error: error instanceof Error ? error.message : String(error),
218
+ });
219
+ continue;
80
220
  }
81
221
  if (statusData.status !== "pending")
82
222
  continue;
83
223
  // ── Pre-flight safety checks ──
84
224
  try {
85
225
  // Check repo_path
86
- const resolvedRepoPath = guardWorkspacePath(statusData.resolved_repo_path || statusData.repo_path || wsRoot, wsRoot);
226
+ const repoPath = typeof statusData.resolved_repo_path === "string"
227
+ ? statusData.resolved_repo_path
228
+ : typeof statusData.repo_path === "string"
229
+ ? statusData.repo_path
230
+ : wsRoot;
231
+ const resolvedRepoPath = guardWorkspacePath(repoPath, wsRoot);
87
232
  // Check agent
88
- guardAgentCommand(statusData.agent, config);
233
+ guardAgentCommand(typeof statusData.agent === "string" ? statusData.agent : "", config);
89
234
  // Check test_command
90
- if (statusData.test_command) {
235
+ if (typeof statusData.test_command === "string" && statusData.test_command) {
91
236
  guardTestCommand(statusData.test_command, config, resolvedRepoPath);
92
237
  }
93
238
  if (Array.isArray(statusData.verify_commands)) {
@@ -96,25 +241,31 @@ async function tick() {
96
241
  }
97
242
  }
98
243
  // Assessment freshness revalidation
99
- if (statusData.assessment_id) {
244
+ if (typeof statusData.assessment_id === "string" && statusData.assessment_id) {
100
245
  const preExecSnapshot = await captureRepoSnapshot(resolvedRepoPath);
101
- const validation = validateAssessmentFreshness(String(statusData.assessment_id), preExecSnapshot);
246
+ const validation = validateAssessmentFreshness(statusData.assessment_id, preExecSnapshot);
102
247
  if (!validation.valid) {
103
248
  throw new Error(`assessment validation failed: ${validation.failure_reason}`);
104
249
  }
105
250
  }
106
251
  }
107
252
  catch (err) {
108
- const errMsg = `[watcher] Safety check failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`;
253
+ const errMsg = redactSensitiveContent(`[watcher] Safety check failed for ${taskId}: ${err instanceof Error ? err.message : String(err)}`).content;
109
254
  logger.error(errMsg);
110
255
  // Write error and mark as failed so it doesn't get re-picked
111
256
  try {
112
- writeFileSync(join(taskDir, "error.log"), errMsg, "utf-8");
113
- const data = JSON.parse(readFileSync(statusFile, "utf-8"));
114
- data.status = "failed";
115
- data.error = errMsg;
116
- data.updated_at = new Date().toISOString();
117
- writeFileSync(statusFile, JSON.stringify(data, null, 2), "utf-8");
257
+ atomicWriteFileSync(join(taskDir, "error.log"), errMsg);
258
+ mutateTaskStatus(statusFile, (current) => {
259
+ if (current.status !== "pending")
260
+ return { result: undefined };
261
+ const next = {
262
+ ...current,
263
+ status: "failed",
264
+ error: errMsg,
265
+ updated_at: new Date().toISOString(),
266
+ };
267
+ return { next, result: undefined };
268
+ });
118
269
  }
119
270
  catch (err) {
120
271
  logger.warn("watcher error-log write failed", { error: err instanceof Error ? err.message : String(err) });
@@ -126,6 +277,7 @@ async function tick() {
126
277
  logger.info(`[watcher] Executing: ${taskId}`);
127
278
  executedTasks.add(taskId);
128
279
  try {
280
+ const { runTask } = await import("./runTask.js");
129
281
  const result = await runTask(taskId);
130
282
  logger.info(`[watcher] ${taskId} → ${result.status}`);
131
283
  }
@@ -133,22 +285,78 @@ async function tick() {
133
285
  logger.error(`[watcher] ${taskId} → error: ${err instanceof Error ? err.message : String(err)}`);
134
286
  }
135
287
  }
288
+ consecutiveTickFailures = 0;
289
+ writeWatcherHeartbeat("running");
290
+ }
291
+ catch (err) {
292
+ consecutiveTickFailures += 1;
293
+ const message = err instanceof Error ? err.message : String(err);
294
+ logger.error(`[watcher] Tick error (${consecutiveTickFailures} consecutive): ${message}`);
295
+ try {
296
+ writeWatcherHeartbeat(consecutiveTickFailures >= 3 ? "degraded" : "running", message);
297
+ }
298
+ catch (heartbeatError) {
299
+ logger.error("[watcher] Failed to persist degraded heartbeat", {
300
+ error: heartbeatError instanceof Error ? heartbeatError.message : String(heartbeatError),
301
+ });
302
+ }
303
+ }
304
+ }
305
+ function writeWatcherHeartbeat(status, lastError) {
306
+ atomicWriteFileSync(WATCHER_HEARTBEAT_FILE, JSON.stringify({
307
+ status,
308
+ pid: process.pid,
309
+ started_at: WATCHER_STARTED_AT,
310
+ last_heartbeat_at: new Date().toISOString(),
311
+ instance_id: WATCHER_INSTANCE_ID,
312
+ launcher_pid: WATCHER_LAUNCHER_PID,
313
+ consecutive_failures: consecutiveTickFailures,
314
+ last_error: lastError ? lastError.slice(0, 500) : null,
315
+ }, null, 2));
316
+ }
317
+ // ── Start (only when executed as a script) ─────────────────────────
318
+ // The bootstrap below has process-wide side effects (mkdir, signal handlers,
319
+ // polling interval, lock acquisition). Guarding it with a main-module check
320
+ // keeps `import { acquireWatcherLock }` safe for unit tests — without it,
321
+ // importing this module would start polling the real tasks dir and register
322
+ // SIGINT/SIGTERM handlers that interfere with the test runner.
323
+ const isMainModule = !!process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
324
+ if (isMainModule) {
325
+ mkdirSync(dirname(WATCHER_HEARTBEAT_FILE), { recursive: true });
326
+ logger.info(`[watcher] Workspace: ${wsRoot}`);
327
+ logger.info(`[watcher] Tasks: ${tasksDir}`);
328
+ logger.info(`[watcher] Polling every ${POLL_INTERVAL_MS / 1000}s`);
329
+ logger.info(`[watcher] Press Ctrl+C to stop`);
330
+ try {
331
+ acquireWatcherLock(WATCHER_LOCK_FILE);
136
332
  }
137
333
  catch (err) {
138
- logger.error(`[watcher] Tick error: ${err instanceof Error ? err.message : String(err)}`);
334
+ if (err instanceof WatcherAlreadyRunningError) {
335
+ logger.error(err.message);
336
+ process.exit(1);
337
+ }
338
+ throw err;
339
+ }
340
+ logger.info("[watcher] Started");
341
+ const scheduledTick = createNonOverlappingRunner(tick, () => {
342
+ logger.warn("[watcher] Previous tick is still running; skipping overlapping poll");
343
+ });
344
+ setInterval(() => { void scheduledTick(); }, POLL_INTERVAL_MS);
345
+ // Run first tick immediately
346
+ void scheduledTick();
347
+ // Graceful shutdown
348
+ function shutdown() {
349
+ logger.info("[watcher] Stopped");
350
+ try {
351
+ releaseWatcherLock(WATCHER_LOCK_FILE);
352
+ }
353
+ catch (error) {
354
+ logger.warn("[watcher] Failed to release watcher lock", {
355
+ error: error instanceof Error ? error.message : String(error),
356
+ });
357
+ }
358
+ process.exit(0);
139
359
  }
360
+ process.once("SIGINT", shutdown);
361
+ process.once("SIGTERM", shutdown);
140
362
  }
141
- // ── Start ─────────────────────────────────────────────────────────
142
- logger.info("[watcher] Started");
143
- setInterval(tick, POLL_INTERVAL_MS);
144
- // Run first tick immediately
145
- tick();
146
- // Graceful shutdown
147
- process.on("SIGINT", () => {
148
- logger.info("[watcher] Stopped");
149
- process.exit(0);
150
- });
151
- process.on("SIGTERM", () => {
152
- logger.info("[watcher] Stopped");
153
- process.exit(0);
154
- });