discoclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,1491 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fsSync from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { collectRuntimeText } from './runtime-utils.js';
7
+ import { parseAuditVerdict } from './forge-audit-verdict.js';
8
+ import { extractFirstJsonValue } from './json-extract.js';
9
+ const ROLLOUT_ERROR_PATTERNS = [
10
+ /rollout path missing/i,
11
+ /session state appears corrupted/i,
12
+ /state db.*rollout/i,
13
+ ];
14
+ function isRolloutPathMissingError(error) {
15
+ if (!error)
16
+ return false;
17
+ return ROLLOUT_ERROR_PATTERNS.some((pattern) => pattern.test(error));
18
+ }
19
+ // ---------------------------------------------------------------------------
20
+ // Constants
21
+ // ---------------------------------------------------------------------------
22
+ const VALID_STATUSES = new Set(['pending', 'in-progress', 'done', 'failed', 'skipped']);
23
+ const VALID_KINDS = new Set(['implement', 'read', 'audit']);
24
+ const PHASES_STATE_VERSION = 1;
25
+ /** Known workspace filenames that should be normalized to workspace/ prefix. */
26
+ const KNOWN_WORKSPACE_FILES = new Set([
27
+ 'TOOLS.md', 'AGENTS.md', 'MEMORY.md', 'SOUL.md', 'IDENTITY.md', 'USER.md',
28
+ ]);
29
+ /** Hardcoded project directory map. */
30
+ const PROJECT_DIRS = {
31
+ discoclaw: path.join(os.homedir(), 'code/discoclaw'),
32
+ };
33
+ // ---------------------------------------------------------------------------
34
+ // Pure functions (no I/O)
35
+ // ---------------------------------------------------------------------------
36
+ export function computePlanHash(planContent) {
37
+ return createHash('sha256').update(planContent).digest('hex').slice(0, 16);
38
+ }
39
+ export function extractFilePaths(changesSection) {
40
+ const deterministic = extractFilePathsDeterministic(changesSection);
41
+ const legacy = extractFilePathsLegacy(changesSection);
42
+ const merged = [];
43
+ const seen = new Set();
44
+ for (const candidate of [...deterministic, ...legacy]) {
45
+ if (seen.has(candidate))
46
+ continue;
47
+ seen.add(candidate);
48
+ merged.push(candidate);
49
+ }
50
+ return merged;
51
+ }
52
+ function extractFilePathsDeterministic(changesSection) {
53
+ const paths = [];
54
+ const seen = new Set();
55
+ let inFence = false;
56
+ for (const line of changesSection.split('\n')) {
57
+ const trimmed = line.trimStart();
58
+ if (trimmed.startsWith('```')) {
59
+ inFence = !inFence;
60
+ continue;
61
+ }
62
+ if (inFence)
63
+ continue;
64
+ const isHeading = /^#{1,6}\s+/.test(trimmed);
65
+ const isList = /^[-*+]\s+/.test(trimmed);
66
+ const isBoldEntry = /^\*{2,3}`[^`]+`\*{2,3}/.test(trimmed);
67
+ if (!isHeading && !isList && !isBoldEntry)
68
+ continue;
69
+ for (const token of extractBacktickTokens(line)) {
70
+ if (!isLikelyFilePath(token))
71
+ continue;
72
+ if (seen.has(token))
73
+ continue;
74
+ seen.add(token);
75
+ paths.push(token);
76
+ }
77
+ }
78
+ return paths;
79
+ }
80
+ function extractFilePathsLegacy(changesSection) {
81
+ const paths = [];
82
+ const seen = new Set();
83
+ const regexes = [
84
+ /^[\s]*-\s+(?:\*{1,3})?`([^`]+)`(?:\*{1,3})?/gm,
85
+ /^#{1,6}\s+(?:\*{1,3})?`([^`]+)`(?:\*{1,3})?/gm,
86
+ /^\s*\*{2,3}`([^`]+)`\*{2,3}(?:\s*[—–:-].*)?$/gm,
87
+ ];
88
+ for (const regex of regexes) {
89
+ let m;
90
+ while ((m = regex.exec(changesSection)) !== null) {
91
+ const candidate = m[1];
92
+ if (!isLikelyFilePath(candidate))
93
+ continue;
94
+ if (seen.has(candidate))
95
+ continue;
96
+ seen.add(candidate);
97
+ paths.push(candidate);
98
+ }
99
+ }
100
+ return paths;
101
+ }
102
+ function extractBacktickTokens(line) {
103
+ const out = [];
104
+ let i = 0;
105
+ while (i < line.length) {
106
+ const start = line.indexOf('`', i);
107
+ if (start === -1)
108
+ break;
109
+ const end = line.indexOf('`', start + 1);
110
+ if (end === -1)
111
+ break;
112
+ const token = line.slice(start + 1, end).trim();
113
+ if (token)
114
+ out.push(token);
115
+ i = end + 1;
116
+ }
117
+ return out;
118
+ }
119
+ function isLikelyFilePath(s) {
120
+ // Must contain / or file extension
121
+ if (!s.includes('/') && !s.includes('.'))
122
+ return false;
123
+ // Reject ALL_CAPS identifiers (config keys like PLAN_PHASES_ENABLED)
124
+ if (/^[A-Z][A-Z0-9_]+$/.test(s))
125
+ return false;
126
+ // Reject PascalCase type names (PlanPhase, RunPhaseResult)
127
+ if (/^[A-Z][a-zA-Z]+$/.test(s) && !s.includes('/') && !s.includes('.'))
128
+ return false;
129
+ // Reject quoted strings ('pending', 'done')
130
+ if (s.startsWith("'") || s.startsWith('"'))
131
+ return false;
132
+ // Reject single words without path separators or extensions
133
+ if (!s.includes('/') && !/\.\w+$/.test(s))
134
+ return false;
135
+ return true;
136
+ }
137
+ export function groupFiles(filePaths, maxPerGroup) {
138
+ if (filePaths.length === 0)
139
+ return [];
140
+ // 1. Pair module + test files
141
+ const paired = new Map();
142
+ const testSuffixes = ['.test.ts', '.test.js', '.spec.ts', '.spec.js'];
143
+ const assignedToModule = new Set();
144
+ for (const fp of filePaths) {
145
+ const isTest = testSuffixes.some((s) => fp.endsWith(s));
146
+ if (isTest) {
147
+ // Find the module this test belongs to
148
+ let moduleFile;
149
+ for (const suffix of testSuffixes) {
150
+ if (fp.endsWith(suffix)) {
151
+ moduleFile = fp.slice(0, -suffix.length) + fp.slice(-suffix.length).replace(/\.(test|spec)\./, '.');
152
+ break;
153
+ }
154
+ }
155
+ if (moduleFile && filePaths.includes(moduleFile)) {
156
+ if (!paired.has(moduleFile))
157
+ paired.set(moduleFile, [moduleFile]);
158
+ paired.get(moduleFile).push(fp);
159
+ assignedToModule.add(fp);
160
+ assignedToModule.add(moduleFile);
161
+ }
162
+ }
163
+ }
164
+ // 2. Group remaining files by directory
165
+ const dirGroups = new Map();
166
+ for (const fp of filePaths) {
167
+ if (assignedToModule.has(fp))
168
+ continue;
169
+ const dir = path.dirname(fp);
170
+ if (!dirGroups.has(dir))
171
+ dirGroups.set(dir, []);
172
+ dirGroups.get(dir).push(fp);
173
+ }
174
+ // 3. Merge paired groups + directory groups, respecting maxPerGroup
175
+ const result = [];
176
+ // Add paired groups first
177
+ for (const group of paired.values()) {
178
+ if (group.length <= maxPerGroup) {
179
+ result.push(group);
180
+ }
181
+ else {
182
+ // Shouldn't happen (pairs are 2), but handle it
183
+ for (let i = 0; i < group.length; i += maxPerGroup) {
184
+ result.push(group.slice(i, i + maxPerGroup));
185
+ }
186
+ }
187
+ }
188
+ // Add directory groups, merging small ones and splitting large ones
189
+ for (const [, files] of dirGroups) {
190
+ // Try to merge into the last result group if it's from the same directory and has room
191
+ if (files.length <= maxPerGroup) {
192
+ result.push(files);
193
+ }
194
+ else {
195
+ for (let i = 0; i < files.length; i += maxPerGroup) {
196
+ result.push(files.slice(i, i + maxPerGroup));
197
+ }
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+ export function extractChangeSpec(changesSection, filePaths) {
203
+ const blocks = [];
204
+ const lines = changesSection.split('\n');
205
+ for (const fp of filePaths) {
206
+ let capturing = false;
207
+ let block = [];
208
+ let foundIndent = -1;
209
+ for (let i = 0; i < lines.length; i++) {
210
+ const line = lines[i];
211
+ // Check if this line starts a top-level list item with our file path
212
+ const topMatch = line.match(/^(\s*)-\s+`([^`]+)`/);
213
+ if (topMatch) {
214
+ const indent = topMatch[1].length;
215
+ const matchedPath = topMatch[2];
216
+ if (capturing) {
217
+ // Hit a new top-level item — stop capturing
218
+ if (indent <= foundIndent) {
219
+ capturing = false;
220
+ }
221
+ }
222
+ if (!capturing && matchedPath === fp) {
223
+ capturing = true;
224
+ foundIndent = indent;
225
+ block = [line];
226
+ continue;
227
+ }
228
+ }
229
+ if (capturing) {
230
+ // Check if this is a new top-level item (same or less indent) or a section header
231
+ if (line.match(/^#{1,4}\s/) || (topMatch && topMatch[1].length <= foundIndent)) {
232
+ capturing = false;
233
+ }
234
+ else {
235
+ block.push(line);
236
+ }
237
+ }
238
+ }
239
+ if (block.length > 0) {
240
+ blocks.push(block.join('\n'));
241
+ }
242
+ else {
243
+ blocks.push(`File \`${fp}\` — not described in Changes section; create per Objective.`);
244
+ }
245
+ }
246
+ return blocks.join('\n\n');
247
+ }
248
+ export function decomposePlan(planContent, planId, planFile, maxContextFiles = 5) {
249
+ const hash = computePlanHash(planContent);
250
+ const now = new Date().toISOString().split('T')[0];
251
+ const changesSection = extractTopLevelSection(planContent, 'Changes');
252
+ const manifestSection = extractTopLevelSection(planContent, 'Change Manifest');
253
+ const manifestPaths = parseChangeManifest(manifestSection);
254
+ const filePaths = manifestPaths.length > 0
255
+ ? manifestPaths
256
+ : extractFilePaths(changesSection);
257
+ const phases = [];
258
+ if (filePaths.length === 0) {
259
+ // Minimal 2-phase set for plans without file paths
260
+ phases.push({
261
+ id: 'phase-1',
262
+ title: 'Read and analyze plan',
263
+ kind: 'read',
264
+ description: 'Read the plan file and produce analysis notes.',
265
+ status: 'pending',
266
+ dependsOn: [],
267
+ contextFiles: [planFile],
268
+ });
269
+ phases.push({
270
+ id: 'phase-2',
271
+ title: 'Implement plan',
272
+ kind: 'implement',
273
+ description: 'Execute the plan objectives.',
274
+ status: 'pending',
275
+ dependsOn: ['phase-1'],
276
+ contextFiles: [planFile],
277
+ });
278
+ phases.push({
279
+ id: 'phase-3',
280
+ title: 'Post-implementation audit',
281
+ kind: 'audit',
282
+ description: 'Audit the implementation against the plan specification.',
283
+ status: 'pending',
284
+ dependsOn: ['phase-2'],
285
+ contextFiles: [planFile],
286
+ });
287
+ }
288
+ else {
289
+ // Group files into batches
290
+ const groups = groupFiles(filePaths, maxContextFiles);
291
+ const implPhaseIds = [];
292
+ for (let i = 0; i < groups.length; i++) {
293
+ const group = groups[i];
294
+ const phaseId = `phase-${i + 1}`;
295
+ implPhaseIds.push(phaseId);
296
+ // Normalize bare workspace filenames in contextFiles
297
+ const contextFiles = group.map(normalizeWorkspacePath);
298
+ const changeSpec = extractChangeSpec(changesSection, group);
299
+ phases.push({
300
+ id: phaseId,
301
+ title: `Implement ${formatGroupTitle(group)}`,
302
+ kind: 'implement',
303
+ description: `Implement changes for: ${group.map((f) => `\`${f}\``).join(', ')}`,
304
+ status: 'pending',
305
+ dependsOn: i > 0 ? [implPhaseIds[i - 1]] : [],
306
+ contextFiles,
307
+ changeSpec,
308
+ });
309
+ }
310
+ // Post-implementation audit phase
311
+ const auditContextFiles = filePaths.map(normalizeWorkspacePath);
312
+ phases.push({
313
+ id: `phase-${groups.length + 1}`,
314
+ title: 'Post-implementation audit',
315
+ kind: 'audit',
316
+ description: 'Audit all changes against the plan specification.',
317
+ status: 'pending',
318
+ dependsOn: implPhaseIds,
319
+ contextFiles: auditContextFiles,
320
+ });
321
+ }
322
+ return {
323
+ planId,
324
+ planFile,
325
+ planContentHash: hash,
326
+ phases,
327
+ createdAt: now,
328
+ updatedAt: now,
329
+ };
330
+ }
331
+ function extractTopLevelSection(planContent, sectionName) {
332
+ const lines = planContent.split('\n');
333
+ const target = sectionName.trim().toLowerCase();
334
+ let inFence = false;
335
+ let capturing = false;
336
+ const body = [];
337
+ for (const line of lines) {
338
+ const trimmed = line.trimStart();
339
+ if (trimmed.startsWith('```')) {
340
+ inFence = !inFence;
341
+ }
342
+ if (!inFence) {
343
+ const headingMatch = line.match(/^##\s+(.+)$/);
344
+ if (headingMatch) {
345
+ const heading = headingMatch[1].trim().toLowerCase();
346
+ if (!capturing && heading === target) {
347
+ capturing = true;
348
+ continue;
349
+ }
350
+ if (capturing)
351
+ break;
352
+ }
353
+ }
354
+ if (capturing)
355
+ body.push(line);
356
+ }
357
+ return body.join('\n').trim();
358
+ }
359
+ function parseChangeManifest(section) {
360
+ if (!section)
361
+ return [];
362
+ const json = extractFirstJsonValue(section, { arrayOnly: true });
363
+ if (!json)
364
+ return [];
365
+ try {
366
+ const parsed = JSON.parse(json);
367
+ if (!Array.isArray(parsed))
368
+ return [];
369
+ const paths = [];
370
+ const seen = new Set();
371
+ for (const value of parsed) {
372
+ if (typeof value !== 'string')
373
+ continue;
374
+ const candidate = value.trim();
375
+ if (!isLikelyFilePath(candidate))
376
+ continue;
377
+ if (seen.has(candidate))
378
+ continue;
379
+ seen.add(candidate);
380
+ paths.push(candidate);
381
+ }
382
+ return paths;
383
+ }
384
+ catch {
385
+ return [];
386
+ }
387
+ }
388
+ function normalizeWorkspacePath(fp) {
389
+ const basename = path.basename(fp);
390
+ if (KNOWN_WORKSPACE_FILES.has(basename) && !fp.startsWith('workspace/') && !fp.includes('/')) {
391
+ return `workspace/${fp}`;
392
+ }
393
+ return fp;
394
+ }
395
+ function formatGroupTitle(files) {
396
+ if (files.length === 1)
397
+ return path.basename(files[0]);
398
+ const dir = path.dirname(files[0]);
399
+ if (files.every((f) => path.dirname(f) === dir)) {
400
+ return `${dir}/ (${files.length} files)`;
401
+ }
402
+ return `${files.length} files`;
403
+ }
404
+ // ---------------------------------------------------------------------------
405
+ // Serialization
406
+ // ---------------------------------------------------------------------------
407
+ export function serializePhases(phases) {
408
+ const lines = [];
409
+ lines.push(`# Phases: ${phases.planId} — ${phases.planFile}`);
410
+ lines.push(`Created: ${phases.createdAt}`);
411
+ lines.push(`Updated: ${phases.updatedAt}`);
412
+ lines.push(`Plan hash: ${phases.planContentHash}`);
413
+ lines.push('');
414
+ for (const phase of phases.phases) {
415
+ lines.push(`## ${phase.id}: ${phase.title}`);
416
+ lines.push(`**Kind:** ${phase.kind}`);
417
+ lines.push(`**Status:** ${phase.status}`);
418
+ lines.push(`**Context:** ${phase.contextFiles.map((f) => `\`${f}\``).join(', ') || '(none)'}`);
419
+ lines.push(`**Depends on:** ${phase.dependsOn.length > 0 ? phase.dependsOn.join(', ') : '(none)'}`);
420
+ if (phase.gitCommit)
421
+ lines.push(`**Git commit:** ${phase.gitCommit}`);
422
+ if (phase.modifiedFiles && phase.modifiedFiles.length > 0) {
423
+ lines.push(`**Modified files:** ${phase.modifiedFiles.map((f) => `\`${f}\``).join(', ')}`);
424
+ }
425
+ if (phase.failureHashes) {
426
+ lines.push(`**Failure hashes:** ${JSON.stringify(phase.failureHashes)}`);
427
+ }
428
+ lines.push('');
429
+ lines.push(phase.description);
430
+ if (phase.changeSpec) {
431
+ lines.push('');
432
+ lines.push('**Change spec:**');
433
+ lines.push(phase.changeSpec);
434
+ }
435
+ if (phase.output) {
436
+ lines.push('');
437
+ lines.push(`**Output:** ${phase.output}`);
438
+ }
439
+ if (phase.error) {
440
+ lines.push('');
441
+ lines.push(`**Error:** ${phase.error}`);
442
+ }
443
+ lines.push('');
444
+ lines.push('---');
445
+ lines.push('');
446
+ }
447
+ return lines.join('\n');
448
+ }
449
+ export function deserializePhases(content) {
450
+ const headerMatch = content.match(/^# Phases:\s*(\S+)\s*—\s*(.+)$/m);
451
+ if (!headerMatch)
452
+ throw new Error('Malformed phases file: missing header');
453
+ const planId = headerMatch[1];
454
+ const planFile = headerMatch[2].trim();
455
+ const createdMatch = content.match(/^Created:\s*(.+)$/m);
456
+ const updatedMatch = content.match(/^Updated:\s*(.+)$/m);
457
+ const hashMatch = content.match(/^Plan hash:\s*(\S+)$/m);
458
+ if (!hashMatch)
459
+ throw new Error('Malformed phases file: missing plan hash');
460
+ const createdAt = createdMatch?.[1]?.trim() ?? '';
461
+ const updatedAt = updatedMatch?.[1]?.trim() ?? '';
462
+ const planContentHash = hashMatch[1];
463
+ // Split into phase sections
464
+ const phaseSections = content.split(/^## /m).slice(1); // first split is the header
465
+ const phases = [];
466
+ for (const section of phaseSections) {
467
+ const idTitleMatch = section.match(/^(phase-\d+):\s*(.+)$/m);
468
+ if (!idTitleMatch)
469
+ continue;
470
+ const id = idTitleMatch[1];
471
+ const title = idTitleMatch[2].trim();
472
+ const kindMatch = section.match(/^\*\*Kind:\*\*\s*(\S+)/m);
473
+ const statusMatch = section.match(/^\*\*Status:\*\*\s*(\S+)/m);
474
+ const contextMatch = section.match(/^\*\*Context:\*\*\s*(.+)$/m);
475
+ const dependsMatch = section.match(/^\*\*Depends on:\*\*\s*(.+)$/m);
476
+ const commitMatch = section.match(/^\*\*Git commit:\*\*\s*(\S+)/m);
477
+ const modifiedMatch = section.match(/^\*\*Modified files:\*\*\s*(.+)$/m);
478
+ const failureHashesMatch = section.match(/^\*\*Failure hashes:\*\*\s*(.+)$/m);
479
+ const outputMatch = section.match(/^\*\*Output:\*\*\s*([\s\S]*?)(?=\n\*\*(?:Error|Change spec):\*\*|\n---|\n$)/m);
480
+ const errorMatch = section.match(/^\*\*Error:\*\*\s*([\s\S]*?)(?=\n\*\*(?:Output|Change spec):\*\*|\n---|\n$)/m);
481
+ const changeSpecMatch = section.match(/^\*\*Change spec:\*\*\n([\s\S]*?)(?=\n\*\*(?:Output|Error):\*\*|\n---|\n$)/m);
482
+ const kindValue = kindMatch?.[1]?.trim() ?? 'implement';
483
+ const statusValue = statusMatch?.[1]?.trim() ?? 'pending';
484
+ if (!VALID_KINDS.has(kindValue)) {
485
+ throw new Error(`Unknown phase kind: '${kindValue}' in ${id}`);
486
+ }
487
+ if (!VALID_STATUSES.has(statusValue)) {
488
+ throw new Error(`Unknown phase status: '${statusValue}' in ${id}`);
489
+ }
490
+ const contextRaw = contextMatch?.[1]?.trim() ?? '(none)';
491
+ const contextFiles = contextRaw === '(none)'
492
+ ? []
493
+ : [...contextRaw.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
494
+ const dependsRaw = dependsMatch?.[1]?.trim() ?? '(none)';
495
+ const dependsOn = dependsRaw === '(none)'
496
+ ? []
497
+ : dependsRaw.split(',').map((s) => s.trim()).filter(Boolean);
498
+ // Extract description: text between metadata lines and first **field or ---
499
+ const metadataEnd = section.indexOf('\n\n');
500
+ let description = '';
501
+ if (metadataEnd !== -1) {
502
+ const afterMetadata = section.slice(metadataEnd + 2);
503
+ // Description is everything until the first **field or ---
504
+ const descEnd = afterMetadata.search(/^\*\*(Change spec|Output|Error|Modified files|Failure hashes):\*\*/m);
505
+ const dashEnd = afterMetadata.indexOf('\n---');
506
+ const cutoff = descEnd >= 0 ? descEnd : (dashEnd >= 0 ? dashEnd : afterMetadata.length);
507
+ description = afterMetadata.slice(0, cutoff).trim();
508
+ }
509
+ const phase = {
510
+ id,
511
+ title,
512
+ kind: kindValue,
513
+ description,
514
+ status: statusValue,
515
+ dependsOn,
516
+ contextFiles,
517
+ };
518
+ if (changeSpecMatch)
519
+ phase.changeSpec = changeSpecMatch[1].trim();
520
+ if (outputMatch)
521
+ phase.output = outputMatch[1].trim();
522
+ if (errorMatch)
523
+ phase.error = errorMatch[1].trim();
524
+ if (commitMatch)
525
+ phase.gitCommit = commitMatch[1];
526
+ if (modifiedMatch) {
527
+ phase.modifiedFiles = [...modifiedMatch[1].matchAll(/`([^`]+)`/g)].map((m) => m[1]);
528
+ }
529
+ if (failureHashesMatch) {
530
+ try {
531
+ phase.failureHashes = JSON.parse(failureHashesMatch[1]);
532
+ }
533
+ catch {
534
+ throw new Error(`Malformed failureHashes in ${id}`);
535
+ }
536
+ }
537
+ phases.push(phase);
538
+ }
539
+ if (phases.length === 0) {
540
+ throw new Error('Malformed phases file: no phases found');
541
+ }
542
+ return {
543
+ planId,
544
+ planFile,
545
+ planContentHash,
546
+ phases,
547
+ createdAt,
548
+ updatedAt,
549
+ };
550
+ }
551
+ // ---------------------------------------------------------------------------
552
+ // Phase navigation
553
+ // ---------------------------------------------------------------------------
554
+ export function getNextPhase(phases) {
555
+ // Priority 1: resume in-progress
556
+ const inProgress = phases.phases.find((p) => p.status === 'in-progress');
557
+ if (inProgress)
558
+ return inProgress;
559
+ // Priority 2: retry failed
560
+ const failed = phases.phases.find((p) => p.status === 'failed');
561
+ if (failed)
562
+ return failed;
563
+ // Priority 3: first pending with all deps met
564
+ for (const phase of phases.phases) {
565
+ if (phase.status !== 'pending')
566
+ continue;
567
+ const depsMet = phase.dependsOn.every((depId) => {
568
+ const dep = phases.phases.find((p) => p.id === depId);
569
+ return dep?.status === 'done' || dep?.status === 'skipped';
570
+ });
571
+ if (depsMet)
572
+ return phase;
573
+ }
574
+ return null;
575
+ }
576
+ // ---------------------------------------------------------------------------
577
+ // State updates (immutable)
578
+ // ---------------------------------------------------------------------------
579
+ export function updatePhaseStatus(phases, phaseId, status, output, error) {
580
+ const now = new Date().toISOString().split('T')[0];
581
+ return {
582
+ ...phases,
583
+ updatedAt: now,
584
+ phases: phases.phases.map((p) => {
585
+ if (p.id !== phaseId)
586
+ return p;
587
+ return {
588
+ ...p,
589
+ status,
590
+ ...(output !== undefined ? { output } : {}),
591
+ ...(error !== undefined ? { error } : {}),
592
+ };
593
+ }),
594
+ };
595
+ }
596
+ export function checkStaleness(phases, currentPlanContent) {
597
+ const currentHash = computePlanHash(currentPlanContent);
598
+ if (currentHash !== phases.planContentHash) {
599
+ return {
600
+ stale: true,
601
+ message: 'Plan file has changed since phases were generated — the existing phases may not match the current plan intent and cannot run safely.\n\n' +
602
+ '**Fix:** `!plan phases --regenerate <plan-id>`\n\n' +
603
+ 'This regenerates phases from the current plan content. All phase statuses are reset to `pending` — previously completed phases will be re-executed. Git commits from completed phases are preserved on the branch, but the phase tracker loses their `done` status.',
604
+ };
605
+ }
606
+ return { stale: false, message: '' };
607
+ }
608
+ // ---------------------------------------------------------------------------
609
+ // Shared helpers
610
+ // ---------------------------------------------------------------------------
611
+ /** Extract the ## Objective section from plan content. */
612
+ export function extractObjective(planContent) {
613
+ const objMatch = planContent.match(/## Objective\s*\n([\s\S]*?)(?=\n## )/);
614
+ return objMatch?.[1]?.trim() ?? '(no objective found in plan)';
615
+ }
616
+ // ---------------------------------------------------------------------------
617
+ // Prompt builder
618
+ // ---------------------------------------------------------------------------
619
+ export function buildPhasePrompt(phase, planContent, injectedContext) {
620
+ const lines = [];
621
+ lines.push('## Objective');
622
+ lines.push('');
623
+ lines.push(extractObjective(planContent));
624
+ lines.push('');
625
+ // Inject pre-read workspace context for implement phases
626
+ if (injectedContext) {
627
+ lines.push('## Pre-read Context Files');
628
+ lines.push('');
629
+ lines.push(injectedContext);
630
+ lines.push('');
631
+ }
632
+ if (phase.kind === 'implement') {
633
+ lines.push('## Task');
634
+ lines.push('');
635
+ lines.push(phase.description);
636
+ lines.push('');
637
+ if (phase.changeSpec) {
638
+ lines.push('## Change Specification');
639
+ lines.push('');
640
+ lines.push(phase.changeSpec);
641
+ lines.push('');
642
+ }
643
+ lines.push('## Context Files');
644
+ lines.push('');
645
+ if (phase.contextFiles.length > 0) {
646
+ lines.push('Read these files to understand the current state, then implement the changes:');
647
+ for (const f of phase.contextFiles) {
648
+ lines.push(`- \`${f}\``);
649
+ }
650
+ }
651
+ lines.push('');
652
+ lines.push('## Instructions');
653
+ lines.push('');
654
+ lines.push('Implement the specified changes using the Write, Edit, and Read tools.');
655
+ lines.push('After making changes, output a brief summary of what was changed.');
656
+ }
657
+ else if (phase.kind === 'read') {
658
+ lines.push('## Task');
659
+ lines.push('');
660
+ lines.push(phase.description);
661
+ lines.push('');
662
+ lines.push('## Context Files');
663
+ lines.push('');
664
+ if (phase.contextFiles.length > 0) {
665
+ lines.push('Read and analyze these files:');
666
+ for (const f of phase.contextFiles) {
667
+ lines.push(`- \`${f}\``);
668
+ }
669
+ }
670
+ lines.push('');
671
+ lines.push('## Instructions');
672
+ lines.push('');
673
+ lines.push('Read the specified files and produce analysis notes. Use Read, Glob, and Grep tools only.');
674
+ }
675
+ else {
676
+ // audit
677
+ lines.push('## Task');
678
+ lines.push('');
679
+ lines.push(phase.description);
680
+ lines.push('');
681
+ lines.push('## Context Files');
682
+ lines.push('');
683
+ if (phase.contextFiles.length > 0) {
684
+ lines.push('Audit these files against the plan specification:');
685
+ for (const f of phase.contextFiles) {
686
+ lines.push(`- \`${f}\``);
687
+ }
688
+ }
689
+ lines.push('');
690
+ lines.push('## Instructions');
691
+ lines.push('');
692
+ lines.push('Compare the implementation against the plan specification. For each concern found, use this EXACT format:');
693
+ lines.push('');
694
+ lines.push('**Concern N: [title]**');
695
+ lines.push('Description of the deviation.');
696
+ lines.push('**Severity: blocking | medium | minor | suggestion**');
697
+ lines.push('');
698
+ lines.push('Severity level definitions:');
699
+ lines.push('- **blocking** — Correctness bugs, security issues, architectural flaws, missing critical functionality. The plan cannot ship with this unresolved.');
700
+ lines.push('- **medium** — Substantive improvements that would make the plan better but aren\'t showstoppers. Missing edge case handling, incomplete error paths.');
701
+ lines.push('- **minor** — Small issues: naming, style, minor clarity gaps. Worth noting, not worth looping over.');
702
+ lines.push('- **suggestion** — Ideas for future improvement. Not problems with the current plan.');
703
+ lines.push('');
704
+ lines.push('IMPORTANT: Each concern MUST have its own **Severity: X** line. Do NOT use tables, summary grids, or any other format for severity ratings — the automated fix loop parses these markers to decide whether to trigger revisions.');
705
+ lines.push('');
706
+ lines.push('End with a **Verdict:** line — either "Needs revision." (if any blocking concerns) or "Ready to approve." (if no blocking concerns).');
707
+ lines.push('');
708
+ lines.push('Use Read, Glob, and Grep tools only.');
709
+ }
710
+ return lines.join('\n');
711
+ }
712
+ // ---------------------------------------------------------------------------
713
+ // Post-run summary
714
+ // ---------------------------------------------------------------------------
715
+ /**
716
+ * Build a concise human-readable rollup of all phases after a plan run completes.
717
+ * Returns an empty string when there are no phases.
718
+ * The `budgetChars` parameter (default 800) caps total output length — if the
719
+ * files list exceeds budget, it is truncated with an overflow count.
720
+ */
721
+ export function buildPostRunSummary(phases, budgetChars = 800) {
722
+ if (phases.phases.length === 0)
723
+ return '';
724
+ const statusIndicator = {
725
+ 'done': '[x]',
726
+ 'failed': '[!]',
727
+ 'skipped': '[-]',
728
+ 'in-progress': '[~]',
729
+ 'pending': '[ ]',
730
+ };
731
+ const lines = [];
732
+ // Per-phase lines
733
+ for (const phase of phases.phases) {
734
+ const indicator = statusIndicator[phase.status] ?? '[ ]';
735
+ const commit = phase.gitCommit ? ` (${phase.gitCommit})` : '';
736
+ const fileCount = phase.modifiedFiles && phase.modifiedFiles.length > 0
737
+ ? ` · ${phase.modifiedFiles.length} file${phase.modifiedFiles.length === 1 ? '' : 's'}`
738
+ : '';
739
+ let line = `${indicator} **${phase.id}:** ${phase.title}${commit}${fileCount}`;
740
+ // For audit phases, append a one-line verdict extracted from output
741
+ if (phase.kind === 'audit' && phase.output) {
742
+ const verdictMatch = phase.output.match(/\*\*Verdict:\*\*\s*(.+)/);
743
+ if (verdictMatch) {
744
+ line += ` — ${verdictMatch[1].trim()}`;
745
+ }
746
+ }
747
+ lines.push(line);
748
+ }
749
+ // Collect all unique modified files across phases
750
+ const allFiles = [];
751
+ const seen = new Set();
752
+ for (const phase of phases.phases) {
753
+ for (const f of phase.modifiedFiles ?? []) {
754
+ if (!seen.has(f)) {
755
+ seen.add(f);
756
+ allFiles.push(f);
757
+ }
758
+ }
759
+ }
760
+ // Build phase section
761
+ const phaseSection = lines.join('\n');
762
+ if (allFiles.length === 0) {
763
+ return phaseSection;
764
+ }
765
+ // Build files section with budget enforcement
766
+ const headerLine = `\n\n**Files changed (${allFiles.length}):**`;
767
+ const budgetForFiles = Math.max(0, budgetChars - phaseSection.length - headerLine.length - 5); // 5 chars margin
768
+ const fileLines = [];
769
+ let usedChars = 0;
770
+ let overflow = 0;
771
+ for (const f of allFiles) {
772
+ const entry = `\`${f}\``;
773
+ if (usedChars + entry.length + 2 > budgetForFiles) {
774
+ overflow = allFiles.length - fileLines.length;
775
+ break;
776
+ }
777
+ fileLines.push(entry);
778
+ usedChars += entry.length + 2; // +2 for ", " separator
779
+ }
780
+ let filesSection = headerLine + '\n' + fileLines.join(', ');
781
+ if (overflow > 0) {
782
+ filesSection += ` (+${overflow} more)`;
783
+ }
784
+ return phaseSection + filesSection;
785
+ }
786
+ export function buildAuditFixPrompt(planContent, auditOutput, contextFiles, modifiedFilesList, attemptNumber, maxAttempts) {
787
+ const lines = [];
788
+ lines.push('## Objective');
789
+ lines.push('');
790
+ lines.push(extractObjective(planContent));
791
+ lines.push('');
792
+ lines.push('## Task');
793
+ lines.push('');
794
+ if (attemptNumber === maxAttempts) {
795
+ lines.push(`Fix attempt ${attemptNumber} of ${maxAttempts} — this is your last chance. Make minimal, targeted fixes only.`);
796
+ }
797
+ else {
798
+ lines.push(`Fix attempt ${attemptNumber} of ${maxAttempts}.`);
799
+ }
800
+ lines.push('The post-implementation audit found deviations that need fixing.');
801
+ lines.push('');
802
+ lines.push('## Audit Findings');
803
+ lines.push('');
804
+ lines.push(auditOutput);
805
+ lines.push('');
806
+ lines.push('## Context Files');
807
+ lines.push('');
808
+ for (const f of contextFiles) {
809
+ lines.push(`- \`${f}\``);
810
+ }
811
+ lines.push('');
812
+ if (modifiedFilesList.length > 0) {
813
+ lines.push('## Modified Files');
814
+ lines.push('');
815
+ for (const f of modifiedFilesList) {
816
+ lines.push(`- \`${f}\``);
817
+ }
818
+ lines.push('');
819
+ }
820
+ lines.push('## Instructions');
821
+ lines.push('');
822
+ lines.push('Fix only the specific deviations identified in the audit. Do not refactor, reorganize, or modify code that the audit did not flag.');
823
+ lines.push('You have read/write file tools only — you cannot run tests, build commands, or install packages. Focus on code-level fixes.');
824
+ lines.push('After making changes, output a brief summary of what was fixed.');
825
+ return lines.join('\n');
826
+ }
827
+ // ---------------------------------------------------------------------------
828
+ // Project directory resolution
829
+ // ---------------------------------------------------------------------------
830
+ export function resolveProjectCwd(planContent, workspaceCwd, projectDirMap = PROJECT_DIRS) {
831
+ const projectMatch = planContent.match(/^\*\*Project:\*\*\s*(.+)$/m);
832
+ if (!projectMatch) {
833
+ throw new Error('Plan has no **Project:** field. Cannot determine source directory.');
834
+ }
835
+ const projectName = projectMatch[1].trim();
836
+ const projectDir = projectDirMap[projectName];
837
+ if (!projectDir) {
838
+ throw new Error(`Project '${projectName}' not in project directory map. Add it to the map in plan-manager.ts or set the **Project:** field to a known project.`);
839
+ }
840
+ // Validate directory exists
841
+ try {
842
+ const stat = fsSync.statSync(projectDir);
843
+ if (!stat.isDirectory()) {
844
+ throw new Error(`Project directory is not a directory: ${projectDir}`);
845
+ }
846
+ }
847
+ catch (err) {
848
+ if (err.code === 'ENOENT') {
849
+ throw new Error(`Project directory does not exist: ${projectDir}`);
850
+ }
851
+ throw err;
852
+ }
853
+ // Note: symlinks to workspaceCwd (e.g. workspace → discoclaw-data/workspace) are
854
+ // allowed here. The real safety gate is resolveContextFilePath, which canonicalizes
855
+ // all paths and checks they resolve under an allowed root (projectCwd or workspaceCwd).
856
+ return projectDir;
857
+ }
858
+ // ---------------------------------------------------------------------------
859
+ // Context file path resolution
860
+ // ---------------------------------------------------------------------------
861
+ export function resolveContextFilePath(filePath, projectCwd, workspaceCwd) {
862
+ let resolved;
863
+ if (filePath.startsWith('workspace/')) {
864
+ // Strip workspace/ prefix and resolve against workspaceCwd
865
+ const stripped = filePath.slice('workspace/'.length);
866
+ resolved = path.resolve(workspaceCwd, stripped);
867
+ }
868
+ else {
869
+ // Resolve against projectCwd
870
+ resolved = path.resolve(projectCwd, filePath);
871
+ }
872
+ // Canonicalize both roots
873
+ const realProjectCwd = safeRealpath(projectCwd);
874
+ const realWorkspaceCwd = safeRealpath(workspaceCwd);
875
+ // Canonicalize the resolved path (handle symlinks, non-existent files)
876
+ const realResolved = safeRealpathWalkUp(resolved);
877
+ // Check if under either root
878
+ if (realResolved === realProjectCwd ||
879
+ realResolved.startsWith(realProjectCwd + path.sep) ||
880
+ realResolved === realWorkspaceCwd ||
881
+ realResolved.startsWith(realWorkspaceCwd + path.sep)) {
882
+ return realResolved;
883
+ }
884
+ throw new Error(`Context file path '${filePath}' resolves to '${realResolved}' which is outside allowed roots ` +
885
+ `(project: ${realProjectCwd}, workspace: ${realWorkspaceCwd})`);
886
+ }
887
+ function safeRealpath(p) {
888
+ try {
889
+ return fsSync.realpathSync(p);
890
+ }
891
+ catch {
892
+ return path.resolve(p);
893
+ }
894
+ }
895
+ /**
896
+ * Resolve realpath, walking up to the nearest existing ancestor for non-existent files.
897
+ */
898
+ function safeRealpathWalkUp(p) {
899
+ try {
900
+ return fsSync.realpathSync(p);
901
+ }
902
+ catch {
903
+ // Walk up to find nearest existing ancestor
904
+ let current = p;
905
+ const remaining = [];
906
+ while (current !== path.dirname(current)) {
907
+ try {
908
+ const real = fsSync.realpathSync(current);
909
+ return path.join(real, ...remaining);
910
+ }
911
+ catch {
912
+ remaining.unshift(path.basename(current));
913
+ current = path.dirname(current);
914
+ }
915
+ }
916
+ // Fallback: no ancestor exists (shouldn't happen in practice)
917
+ return path.resolve(p);
918
+ }
919
+ }
920
+ // ---------------------------------------------------------------------------
921
+ // I/O functions
922
+ // ---------------------------------------------------------------------------
923
+ export function writePhasesFile(filePath, phases) {
924
+ const jsonPath = phasesJsonPath(filePath);
925
+ const jsonContent = serializePhasesStateJson(phases);
926
+ writeTextAtomically(jsonPath, jsonContent);
927
+ const content = serializePhases(phases);
928
+ writeTextAtomically(filePath, content);
929
+ }
930
+ export function readPhasesFile(filePath, opts) {
931
+ const jsonPath = phasesJsonPath(filePath);
932
+ let jsonErr;
933
+ if (fsSync.existsSync(jsonPath)) {
934
+ try {
935
+ const jsonContent = fsSync.readFileSync(jsonPath, 'utf-8');
936
+ return deserializePhasesStateJson(jsonContent);
937
+ }
938
+ catch (err) {
939
+ jsonErr = err;
940
+ opts?.log?.warn({ err, jsonPath }, 'plan-manager: phases json invalid, falling back to markdown');
941
+ }
942
+ }
943
+ try {
944
+ const content = fsSync.readFileSync(filePath, 'utf-8');
945
+ const phases = deserializePhases(content);
946
+ if (opts?.backfillJson ?? true) {
947
+ try {
948
+ const jsonContent = serializePhasesStateJson(phases);
949
+ writeTextAtomically(jsonPath, jsonContent);
950
+ }
951
+ catch (backfillErr) {
952
+ opts?.log?.warn({ err: backfillErr, jsonPath }, 'plan-manager: failed to backfill phases json from markdown');
953
+ }
954
+ }
955
+ return phases;
956
+ }
957
+ catch (mdErr) {
958
+ if (!jsonErr)
959
+ throw mdErr;
960
+ throw new Error(`Failed to read phases state. JSON error: ${String(jsonErr)}. Markdown error: ${String(mdErr)}`);
961
+ }
962
+ }
963
+ function phasesJsonPath(markdownPath) {
964
+ return markdownPath.endsWith('.md')
965
+ ? markdownPath.slice(0, -'.md'.length) + '.json'
966
+ : `${markdownPath}.json`;
967
+ }
968
+ function writeTextAtomically(filePath, content) {
969
+ const tmpPath = filePath + '.tmp';
970
+ fsSync.writeFileSync(tmpPath, content, 'utf-8');
971
+ try {
972
+ fsSync.renameSync(tmpPath, filePath);
973
+ }
974
+ catch (err) {
975
+ try {
976
+ fsSync.unlinkSync(tmpPath);
977
+ }
978
+ catch { /* best-effort cleanup */ }
979
+ throw err;
980
+ }
981
+ }
982
+ function serializePhasesStateJson(phases) {
983
+ const state = {
984
+ version: PHASES_STATE_VERSION,
985
+ ...phases,
986
+ };
987
+ return JSON.stringify(state, null, 2) + '\n';
988
+ }
989
+ function asString(value, field) {
990
+ if (typeof value !== 'string') {
991
+ throw new Error(`Malformed phases json: ${field} must be a string`);
992
+ }
993
+ return value;
994
+ }
995
+ function asStringArray(value, field) {
996
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
997
+ throw new Error(`Malformed phases json: ${field} must be string[]`);
998
+ }
999
+ return value;
1000
+ }
1001
+ function asFailureHashes(value, field) {
1002
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1003
+ throw new Error(`Malformed phases json: ${field} must be Record<string,string>`);
1004
+ }
1005
+ const out = {};
1006
+ for (const [k, v] of Object.entries(value)) {
1007
+ if (typeof v !== 'string') {
1008
+ throw new Error(`Malformed phases json: ${field}.${k} must be a string`);
1009
+ }
1010
+ out[k] = v;
1011
+ }
1012
+ return out;
1013
+ }
1014
+ function deserializePhasesStateJson(raw) {
1015
+ let parsed;
1016
+ try {
1017
+ parsed = JSON.parse(raw);
1018
+ }
1019
+ catch {
1020
+ throw new Error('Malformed phases json: invalid JSON');
1021
+ }
1022
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1023
+ throw new Error('Malformed phases json: expected object');
1024
+ }
1025
+ const obj = parsed;
1026
+ if (obj.version !== PHASES_STATE_VERSION) {
1027
+ throw new Error(`Malformed phases json: unsupported version '${String(obj.version)}'`);
1028
+ }
1029
+ const phasesRaw = obj.phases;
1030
+ if (!Array.isArray(phasesRaw)) {
1031
+ throw new Error('Malformed phases json: phases must be an array');
1032
+ }
1033
+ const phases = phasesRaw.map((phaseRaw, idx) => {
1034
+ if (!phaseRaw || typeof phaseRaw !== 'object' || Array.isArray(phaseRaw)) {
1035
+ throw new Error(`Malformed phases json: phases[${idx}] must be an object`);
1036
+ }
1037
+ const p = phaseRaw;
1038
+ const kind = asString(p.kind, `phases[${idx}].kind`);
1039
+ const status = asString(p.status, `phases[${idx}].status`);
1040
+ if (!VALID_KINDS.has(kind))
1041
+ throw new Error(`Unknown phase kind: '${kind}' in phases[${idx}]`);
1042
+ if (!VALID_STATUSES.has(status))
1043
+ throw new Error(`Unknown phase status: '${status}' in phases[${idx}]`);
1044
+ const phase = {
1045
+ id: asString(p.id, `phases[${idx}].id`),
1046
+ title: asString(p.title, `phases[${idx}].title`),
1047
+ kind: kind,
1048
+ description: asString(p.description, `phases[${idx}].description`),
1049
+ status: status,
1050
+ dependsOn: asStringArray(p.dependsOn, `phases[${idx}].dependsOn`),
1051
+ contextFiles: asStringArray(p.contextFiles, `phases[${idx}].contextFiles`),
1052
+ };
1053
+ if (typeof p.changeSpec === 'string')
1054
+ phase.changeSpec = p.changeSpec;
1055
+ if (typeof p.output === 'string')
1056
+ phase.output = p.output;
1057
+ if (typeof p.error === 'string')
1058
+ phase.error = p.error;
1059
+ if (typeof p.gitCommit === 'string')
1060
+ phase.gitCommit = p.gitCommit;
1061
+ if (p.modifiedFiles !== undefined) {
1062
+ phase.modifiedFiles = asStringArray(p.modifiedFiles, `phases[${idx}].modifiedFiles`);
1063
+ }
1064
+ if (p.failureHashes !== undefined) {
1065
+ phase.failureHashes = asFailureHashes(p.failureHashes, `phases[${idx}].failureHashes`);
1066
+ }
1067
+ return phase;
1068
+ });
1069
+ return {
1070
+ planId: asString(obj.planId, 'planId'),
1071
+ planFile: asString(obj.planFile, 'planFile'),
1072
+ planContentHash: asString(obj.planContentHash, 'planContentHash'),
1073
+ phases,
1074
+ createdAt: asString(obj.createdAt, 'createdAt'),
1075
+ updatedAt: asString(obj.updatedAt, 'updatedAt'),
1076
+ };
1077
+ }
1078
+ export async function executePhase(phase, planContent, phases, opts, injectedContext) {
1079
+ // Derive tools from phase kind
1080
+ const tools = phase.kind === 'implement'
1081
+ ? ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash']
1082
+ : ['Read', 'Glob', 'Grep'];
1083
+ // Derive addDirs based on phase kind
1084
+ let addDirs;
1085
+ if (phase.kind === 'implement') {
1086
+ // Filter out workspace paths for implement phases
1087
+ const realWorkspace = safeRealpath(opts.workspaceCwd);
1088
+ addDirs = opts.addDirs.filter((d) => {
1089
+ const realD = safeRealpath(d);
1090
+ if (realD === realWorkspace || realD.startsWith(realWorkspace + path.sep)) {
1091
+ opts.log?.warn({ path: d }, 'Filtered workspace path from implement phase addDirs');
1092
+ return false;
1093
+ }
1094
+ return true;
1095
+ });
1096
+ }
1097
+ else {
1098
+ // read/audit get workspace access
1099
+ addDirs = [opts.workspaceCwd, ...opts.addDirs];
1100
+ }
1101
+ const prompt = buildPhasePrompt(phase, planContent, injectedContext);
1102
+ try {
1103
+ const output = await collectRuntimeText(opts.runtime, prompt, opts.model, opts.projectCwd, tools, addDirs, opts.timeoutMs, { requireFinalEvent: true, onEvent: opts.onEvent, signal: opts.signal });
1104
+ if (phase.kind === 'audit') {
1105
+ const verdict = parseAuditVerdict(output);
1106
+ if (verdict.shouldLoop) {
1107
+ return { status: 'audit_failed', output, error: `Audit found ${verdict.maxSeverity} severity deviations`, verdict };
1108
+ }
1109
+ }
1110
+ return { status: 'done', output };
1111
+ }
1112
+ catch (err) {
1113
+ const errorMsg = String(err instanceof Error ? err.message : err);
1114
+ return { status: 'failed', output: '', error: errorMsg };
1115
+ }
1116
+ }
1117
+ // ---------------------------------------------------------------------------
1118
+ // Git helpers
1119
+ // ---------------------------------------------------------------------------
1120
+ function gitAvailable(cwd) {
1121
+ try {
1122
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd, encoding: 'utf-8', stdio: 'pipe' });
1123
+ return true;
1124
+ }
1125
+ catch {
1126
+ return false;
1127
+ }
1128
+ }
1129
+ function gitDiffNames(cwd) {
1130
+ try {
1131
+ const result = new Set();
1132
+ const unstaged = execFileSync('git', ['diff', '--name-only'], { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
1133
+ const staged = execFileSync('git', ['diff', '--staged', '--name-only'], { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
1134
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
1135
+ for (const line of [...unstaged.split('\n'), ...staged.split('\n'), ...untracked.split('\n')]) {
1136
+ if (line.trim())
1137
+ result.add(line.trim());
1138
+ }
1139
+ return result;
1140
+ }
1141
+ catch {
1142
+ return null;
1143
+ }
1144
+ }
1145
+ function hashFileContent(filePath) {
1146
+ try {
1147
+ const content = fsSync.readFileSync(filePath, 'utf-8');
1148
+ return createHash('sha256').update(content).digest('hex').slice(0, 16);
1149
+ }
1150
+ catch {
1151
+ return '';
1152
+ }
1153
+ }
1154
+ // ---------------------------------------------------------------------------
1155
+ // High-level runner
1156
+ // ---------------------------------------------------------------------------
1157
+ export async function runNextPhase(phasesFilePath, planFilePath, opts, onProgress) {
1158
+ // 1. Read and deserialize phases file
1159
+ let allPhases;
1160
+ try {
1161
+ allPhases = readPhasesFile(phasesFilePath, { backfillJson: true, log: opts.log });
1162
+ }
1163
+ catch (err) {
1164
+ return { result: 'corrupt', message: `Failed to read phases file: ${String(err)}` };
1165
+ }
1166
+ // 2. Read plan and check staleness
1167
+ let planContent;
1168
+ try {
1169
+ planContent = fsSync.readFileSync(planFilePath, 'utf-8');
1170
+ }
1171
+ catch (err) {
1172
+ return { result: 'corrupt', message: `Failed to read plan file: ${String(err)}` };
1173
+ }
1174
+ const staleness = checkStaleness(allPhases, planContent);
1175
+ if (staleness.stale) {
1176
+ return { result: 'stale', message: staleness.message };
1177
+ }
1178
+ // 3. Get next phase
1179
+ const phase = getNextPhase(allPhases);
1180
+ if (!phase) {
1181
+ return { result: 'nothing_to_run' };
1182
+ }
1183
+ // 4. Retry safety check
1184
+ const isGitAvailable = gitAvailable(opts.projectCwd);
1185
+ const allowRetryDespiteFailure = isRolloutPathMissingError(phase.error);
1186
+ if (phase.status === 'failed' && phase.kind !== 'audit' && !allowRetryDespiteFailure) {
1187
+ if (isGitAvailable) {
1188
+ if (!phase.modifiedFiles || phase.modifiedFiles.length === 0) {
1189
+ return {
1190
+ result: 'retry_blocked',
1191
+ phase,
1192
+ message: 'Phase failed but has no modifiedFiles — cannot safely determine what to revert. Use `!plan skip` or `!plan phases --regenerate`.',
1193
+ };
1194
+ }
1195
+ if (!phase.failureHashes) {
1196
+ return {
1197
+ result: 'retry_blocked',
1198
+ phase,
1199
+ message: 'Phase has modifiedFiles but no failureHashes — cannot safely determine which files to revert. Use `!plan skip` or `!plan phases --regenerate`.',
1200
+ };
1201
+ }
1202
+ }
1203
+ // Non-git: proceed unconditionally
1204
+ }
1205
+ if (opts.onPlanEvent) {
1206
+ try {
1207
+ await opts.onPlanEvent({
1208
+ type: 'phase_start',
1209
+ planId: allPhases.planId,
1210
+ phase: {
1211
+ id: phase.id,
1212
+ title: phase.title,
1213
+ kind: phase.kind,
1214
+ },
1215
+ });
1216
+ }
1217
+ catch (err) {
1218
+ opts.log?.warn({ err, planId: allPhases.planId, phaseId: phase.id }, 'plan-manager: onPlanEvent callback failed');
1219
+ }
1220
+ }
1221
+ // 5. Write in-progress status to disk
1222
+ await onProgress(`**${phase.id}**: Running ${phase.title}...`);
1223
+ allPhases = updatePhaseStatus(allPhases, phase.id, 'in-progress');
1224
+ writePhasesFile(phasesFilePath, allPhases);
1225
+ // 6. Git snapshot (null = git command failed, skip modified-files tracking)
1226
+ const preSnapshot = isGitAvailable ? gitDiffNames(opts.projectCwd) : null;
1227
+ // 7. Auto-revert on retry
1228
+ if (phase.status === 'failed' && phase.modifiedFiles && phase.failureHashes && isGitAvailable && preSnapshot) {
1229
+ // Note: we are re-reading phase from the old allPhases data (before status update).
1230
+ // The status was 'failed' when getNextPhase returned it, and we updated to 'in-progress' in step 5.
1231
+ // The modifiedFiles/failureHashes are from the old data.
1232
+ const origPhase = allPhases.phases.find((p) => p.id === phase.id);
1233
+ const modFiles = origPhase?.modifiedFiles ?? phase.modifiedFiles;
1234
+ const failHashes = origPhase?.failureHashes ?? phase.failureHashes;
1235
+ const trackedToRevert = [];
1236
+ const untrackedToClean = [];
1237
+ for (const file of modFiles) {
1238
+ const currentHash = hashFileContent(path.join(opts.projectCwd, file));
1239
+ const failHash = failHashes[file];
1240
+ if (!failHash || currentHash !== failHash) {
1241
+ await onProgress(`Skipping revert of \`${file}\` — modified since last attempt. Retry will proceed with current state.`);
1242
+ continue;
1243
+ }
1244
+ // Hash matches — safe to revert
1245
+ if (preSnapshot.has(file)) {
1246
+ // File was in pre-snapshot, so it's tracked + dirty or staged
1247
+ trackedToRevert.push(file);
1248
+ }
1249
+ else {
1250
+ // File was not in pre-execution snapshot = created by failed attempt
1251
+ untrackedToClean.push(file);
1252
+ }
1253
+ }
1254
+ if (trackedToRevert.length > 0) {
1255
+ try {
1256
+ execFileSync('git', ['checkout', '--', ...trackedToRevert], { cwd: opts.projectCwd, stdio: 'pipe' });
1257
+ }
1258
+ catch (err) {
1259
+ opts.log?.warn({ err, files: trackedToRevert }, 'plan-manager: revert tracked files failed');
1260
+ }
1261
+ }
1262
+ if (untrackedToClean.length > 0) {
1263
+ try {
1264
+ execFileSync('git', ['clean', '-f', '--', ...untrackedToClean], { cwd: opts.projectCwd, stdio: 'pipe' });
1265
+ }
1266
+ catch (err) {
1267
+ opts.log?.warn({ err, files: untrackedToClean }, 'plan-manager: clean untracked files failed');
1268
+ }
1269
+ }
1270
+ }
1271
+ // 8. Context injection for implement phases
1272
+ const MAX_INJECTED_CONTEXT_BYTES = 100 * 1024; // 100 KB budget
1273
+ let injectedContext;
1274
+ if (phase.kind === 'implement') {
1275
+ const hasWorkspaceFiles = phase.contextFiles.some((cf) => cf.startsWith('workspace/'));
1276
+ if (hasWorkspaceFiles) {
1277
+ await onProgress(`**${phase.id}**: Reading context files...`);
1278
+ }
1279
+ const blocks = [];
1280
+ let totalBytes = 0;
1281
+ for (const cf of phase.contextFiles) {
1282
+ if (!cf.startsWith('workspace/'))
1283
+ continue;
1284
+ const stripped = cf.slice('workspace/'.length);
1285
+ const absPath = path.resolve(opts.workspaceCwd, stripped);
1286
+ try {
1287
+ const content = fsSync.readFileSync(absPath, 'utf-8');
1288
+ const block = `### File: ${cf}\n\`\`\`\n${content}\n\`\`\``;
1289
+ if (totalBytes + block.length > MAX_INJECTED_CONTEXT_BYTES) {
1290
+ opts.log?.warn({ file: cf, size: block.length, budget: MAX_INJECTED_CONTEXT_BYTES }, 'plan-manager: context file exceeds injection budget, skipping');
1291
+ continue;
1292
+ }
1293
+ totalBytes += block.length;
1294
+ blocks.push(block);
1295
+ }
1296
+ catch {
1297
+ opts.log?.warn({ file: cf }, 'plan-manager: context file not found');
1298
+ blocks.push(`### File: ${cf}\n(File not found)`);
1299
+ }
1300
+ }
1301
+ if (blocks.length > 0) {
1302
+ injectedContext = blocks.join('\n\n');
1303
+ }
1304
+ }
1305
+ // 9. Execute the phase
1306
+ // Reload the phase from allPhases to get the updated status
1307
+ const currentPhase = allPhases.phases.find((p) => p.id === phase.id);
1308
+ await onProgress(`**${phase.id}**: Executing ${phase.kind} phase...`);
1309
+ let result = await executePhase(currentPhase, planContent, allPhases, opts, injectedContext);
1310
+ // 9a. Audit fix loop: if audit failed and git is available, attempt fix→re-audit cycles
1311
+ const maxFixAttempts = opts.maxAuditFixAttempts ?? 2;
1312
+ let fixAttemptsUsed;
1313
+ if (result.status === 'audit_failed' && maxFixAttempts > 0) {
1314
+ if (!isGitAvailable) {
1315
+ await onProgress('Automatic fix loop skipped \u2014 git not available.');
1316
+ }
1317
+ else {
1318
+ let lastAuditOutput = result.output;
1319
+ let lastSeverity = result.verdict.maxSeverity;
1320
+ const realWorkspace = safeRealpath(opts.workspaceCwd);
1321
+ const fixAddDirs = opts.addDirs.filter((d) => {
1322
+ const realD = safeRealpath(d);
1323
+ return !(realD === realWorkspace || realD.startsWith(realWorkspace + path.sep));
1324
+ });
1325
+ for (let attempt = 1; attempt <= maxFixAttempts; attempt++) {
1326
+ // Progress message — different wording for first vs subsequent
1327
+ if (attempt === 1) {
1328
+ await onProgress(`**${phase.id}**: Audit found **${lastSeverity}** deviations \u2014 attempting fix (${attempt}/${maxFixAttempts})...`);
1329
+ }
1330
+ else {
1331
+ await onProgress(`**${phase.id}**: Audit still found deviations \u2014 attempting fix (${attempt}/${maxFixAttempts})...`);
1332
+ }
1333
+ // Compute modified files list (fresh each iteration)
1334
+ let modifiedFilesList = [];
1335
+ try {
1336
+ const tracked = execFileSync('git', ['diff', '--name-only', 'HEAD'], { cwd: opts.projectCwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
1337
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: opts.projectCwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
1338
+ const combined = [...(tracked ? tracked.split('\n') : []), ...(untracked ? untracked.split('\n') : [])];
1339
+ modifiedFilesList = [...new Set(combined)];
1340
+ }
1341
+ catch {
1342
+ // git error (no commits, corrupt index) — use empty list and continue
1343
+ }
1344
+ // Build fix prompt with full spec
1345
+ const fixPrompt = buildAuditFixPrompt(planContent, lastAuditOutput, currentPhase.contextFiles, modifiedFilesList, attempt, maxFixAttempts);
1346
+ // Run fix agent — NO Bash tool (safety boundary for automated loop)
1347
+ try {
1348
+ await collectRuntimeText(opts.runtime, fixPrompt, opts.model, opts.projectCwd, ['Read', 'Write', 'Edit', 'Glob', 'Grep'], fixAddDirs, opts.timeoutMs, { requireFinalEvent: true, onEvent: opts.onEvent, signal: opts.signal });
1349
+ }
1350
+ catch (err) {
1351
+ opts.log?.warn({ err, phase: phase.id, attempt }, 'plan-manager: audit fix agent failed');
1352
+ continue; // Consumed attempt — try again or exit loop
1353
+ }
1354
+ // Re-audit
1355
+ await onProgress(`**${phase.id}**: Fix attempt ${attempt} complete. Re-auditing...`);
1356
+ result = await executePhase(currentPhase, planContent, allPhases, opts);
1357
+ if (result.status === 'done') {
1358
+ fixAttemptsUsed = attempt;
1359
+ break;
1360
+ }
1361
+ else if (result.status === 'audit_failed') {
1362
+ lastAuditOutput = result.output;
1363
+ lastSeverity = result.verdict.maxSeverity;
1364
+ }
1365
+ // result.status === 'failed' (runtime error on re-audit) — consumed attempt, continue
1366
+ }
1367
+ // Exhausted fix attempts — rollback all uncommitted changes
1368
+ if (result.status === 'audit_failed' || result.status === 'failed') {
1369
+ fixAttemptsUsed = fixAttemptsUsed ?? maxFixAttempts;
1370
+ try {
1371
+ execFileSync('git', ['checkout', '.'], { cwd: opts.projectCwd, stdio: 'pipe' });
1372
+ execFileSync('git', ['clean', '-fd'], { cwd: opts.projectCwd, stdio: 'pipe' });
1373
+ await onProgress('Fix attempts exhausted \u2014 rolled back fix-agent changes.');
1374
+ }
1375
+ catch (rollbackErr) {
1376
+ await onProgress(`Fix attempts exhausted \u2014 rollback failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}. Working tree may contain partial fix-agent changes.`);
1377
+ }
1378
+ // Normalize: fix loop exhaustion always returns audit_failed, even if
1379
+ // the last iteration was a runtime error ('failed') rather than an audit failure.
1380
+ if (result.status === 'failed') {
1381
+ result = {
1382
+ status: 'audit_failed',
1383
+ output: lastAuditOutput,
1384
+ error: 'Fix loop exhausted after runtime error on re-audit',
1385
+ verdict: { maxSeverity: lastSeverity, shouldLoop: true },
1386
+ };
1387
+ }
1388
+ }
1389
+ }
1390
+ }
1391
+ // 10. Capture modified files (skip if either snapshot is unavailable)
1392
+ const postSnapshot = preSnapshot ? gitDiffNames(opts.projectCwd) : null;
1393
+ const modifiedFiles = [];
1394
+ if (preSnapshot && postSnapshot) {
1395
+ for (const file of postSnapshot) {
1396
+ if (!preSnapshot.has(file)) {
1397
+ modifiedFiles.push(file);
1398
+ }
1399
+ }
1400
+ }
1401
+ // Compute failure hashes if failed
1402
+ let failureHashes;
1403
+ if (result.status === 'failed' && modifiedFiles.length > 0) {
1404
+ failureHashes = {};
1405
+ for (const file of modifiedFiles) {
1406
+ const hash = hashFileContent(path.join(opts.projectCwd, file));
1407
+ if (hash)
1408
+ failureHashes[file] = hash;
1409
+ }
1410
+ }
1411
+ // 11. Write done/failed status to disk
1412
+ const diskStatus = result.status === 'audit_failed' ? 'failed' : result.status;
1413
+ const diskError = result.status === 'done' ? undefined : result.error;
1414
+ allPhases = updatePhaseStatus(allPhases, phase.id, diskStatus, result.output, diskError);
1415
+ // Attach modifiedFiles and failureHashes to the phase
1416
+ allPhases = {
1417
+ ...allPhases,
1418
+ phases: allPhases.phases.map((p) => {
1419
+ if (p.id !== phase.id)
1420
+ return p;
1421
+ return {
1422
+ ...p,
1423
+ modifiedFiles: modifiedFiles.length > 0 ? modifiedFiles : undefined,
1424
+ failureHashes,
1425
+ };
1426
+ }),
1427
+ };
1428
+ writePhasesFile(phasesFilePath, allPhases);
1429
+ // 12. Git commit on success
1430
+ if (result.status === 'done' && isGitAvailable && modifiedFiles.length > 0) {
1431
+ try {
1432
+ execFileSync('git', ['add', ...modifiedFiles], { cwd: opts.projectCwd, stdio: 'pipe' });
1433
+ const commitMsg = `${allPhases.planId} ${phase.id}: ${phase.title}`;
1434
+ execFileSync('git', ['commit', '-m', commitMsg], { cwd: opts.projectCwd, stdio: 'pipe' });
1435
+ // Capture commit hash
1436
+ const commitHash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: opts.projectCwd, encoding: 'utf-8', stdio: 'pipe' }).trim();
1437
+ // Update phase with git commit hash
1438
+ allPhases = {
1439
+ ...allPhases,
1440
+ phases: allPhases.phases.map((p) => {
1441
+ if (p.id !== phase.id)
1442
+ return p;
1443
+ return { ...p, gitCommit: commitHash };
1444
+ }),
1445
+ };
1446
+ writePhasesFile(phasesFilePath, allPhases);
1447
+ }
1448
+ catch (err) {
1449
+ // Unstage files so the next retry doesn't see stale staged state
1450
+ try {
1451
+ execFileSync('git', ['reset'], { cwd: opts.projectCwd, stdio: 'pipe' });
1452
+ }
1453
+ catch { /* best-effort */ }
1454
+ opts.log?.warn({ err, phase: phase.id }, 'plan-manager: git commit failed');
1455
+ }
1456
+ }
1457
+ else if (result.status === 'done' && isGitAvailable && modifiedFiles.length === 0) {
1458
+ opts.log?.warn({ phase: phase.id }, 'plan-manager: phase completed but no files were modified');
1459
+ }
1460
+ const updatedPhase = allPhases.phases.find((p) => p.id === phase.id);
1461
+ // Emit phase_complete event
1462
+ if (opts.onPlanEvent) {
1463
+ const completeStatus = result.status === 'done' ? 'done' : 'failed';
1464
+ try {
1465
+ await opts.onPlanEvent({
1466
+ type: 'phase_complete',
1467
+ planId: allPhases.planId,
1468
+ phase: {
1469
+ id: updatedPhase.id,
1470
+ title: updatedPhase.title,
1471
+ kind: updatedPhase.kind,
1472
+ },
1473
+ status: completeStatus,
1474
+ });
1475
+ }
1476
+ catch (err) {
1477
+ opts.log?.warn({ err, planId: allPhases.planId, phaseId: phase.id }, 'plan-manager: onPlanEvent phase_complete callback failed');
1478
+ }
1479
+ }
1480
+ if (result.status === 'done') {
1481
+ const upcoming = getNextPhase(allPhases);
1482
+ const nextPhase = upcoming ? { id: upcoming.id, title: upcoming.title } : undefined;
1483
+ return { result: 'done', phase: updatedPhase, output: result.output, nextPhase };
1484
+ }
1485
+ else if (result.status === 'audit_failed') {
1486
+ return { result: 'audit_failed', phase: updatedPhase, output: result.output, verdict: result.verdict, fixAttemptsUsed };
1487
+ }
1488
+ else {
1489
+ return { result: 'failed', phase: updatedPhase, output: result.output, error: result.error ?? 'Unknown error' };
1490
+ }
1491
+ }