mstro-app 0.4.51 → 0.5.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 (223) hide show
  1. package/README.md +10 -5
  2. package/bin/mstro.js +1 -1
  3. package/dist/server/cli/headless/claude-invoker-stall.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker-stall.js +7 -2
  5. package/dist/server/cli/headless/claude-invoker-stall.js.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +1 -1
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  9. package/dist/server/cli/headless/runner.js +63 -67
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  12. package/dist/server/cli/headless/stall-assessor.js +9 -4
  13. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  14. package/dist/server/cli/improvisation-history-store.d.ts +16 -0
  15. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -0
  16. package/dist/server/cli/improvisation-history-store.js +52 -0
  17. package/dist/server/cli/improvisation-history-store.js.map +1 -0
  18. package/dist/server/cli/improvisation-movements.d.ts +31 -0
  19. package/dist/server/cli/improvisation-movements.d.ts.map +1 -0
  20. package/dist/server/cli/improvisation-movements.js +93 -0
  21. package/dist/server/cli/improvisation-movements.js.map +1 -0
  22. package/dist/server/cli/improvisation-output-queue.d.ts +13 -0
  23. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -0
  24. package/dist/server/cli/improvisation-output-queue.js +40 -0
  25. package/dist/server/cli/improvisation-output-queue.js.map +1 -0
  26. package/dist/server/cli/improvisation-retry.d.ts +21 -51
  27. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  28. package/dist/server/cli/improvisation-retry.js +18 -433
  29. package/dist/server/cli/improvisation-retry.js.map +1 -1
  30. package/dist/server/cli/improvisation-session-manager.d.ts +10 -8
  31. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.js +53 -148
  33. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  34. package/dist/server/cli/retry/retry-best-result.d.ts +4 -0
  35. package/dist/server/cli/retry/retry-best-result.d.ts.map +1 -0
  36. package/dist/server/cli/retry/retry-best-result.js +61 -0
  37. package/dist/server/cli/retry/retry-best-result.js.map +1 -0
  38. package/dist/server/cli/retry/retry-context-loss.d.ts +6 -0
  39. package/dist/server/cli/retry/retry-context-loss.d.ts.map +1 -0
  40. package/dist/server/cli/retry/retry-context-loss.js +68 -0
  41. package/dist/server/cli/retry/retry-context-loss.js.map +1 -0
  42. package/dist/server/cli/retry/retry-premature-completion.d.ts +5 -0
  43. package/dist/server/cli/retry/retry-premature-completion.d.ts.map +1 -0
  44. package/dist/server/cli/retry/retry-premature-completion.js +81 -0
  45. package/dist/server/cli/retry/retry-premature-completion.js.map +1 -0
  46. package/dist/server/cli/retry/retry-recovery-strategies.d.ts +13 -0
  47. package/dist/server/cli/retry/retry-recovery-strategies.d.ts.map +1 -0
  48. package/dist/server/cli/retry/retry-recovery-strategies.js +166 -0
  49. package/dist/server/cli/retry/retry-recovery-strategies.js.map +1 -0
  50. package/dist/server/cli/retry/retry-resume-strategy.d.ts +12 -0
  51. package/dist/server/cli/retry/retry-resume-strategy.d.ts.map +1 -0
  52. package/dist/server/cli/retry/retry-resume-strategy.js +22 -0
  53. package/dist/server/cli/retry/retry-resume-strategy.js.map +1 -0
  54. package/dist/server/cli/retry/retry-runner-factory.d.ts +11 -0
  55. package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -0
  56. package/dist/server/cli/retry/retry-runner-factory.js +60 -0
  57. package/dist/server/cli/retry/retry-runner-factory.js.map +1 -0
  58. package/dist/server/cli/retry/retry-tool-results.d.ts +9 -0
  59. package/dist/server/cli/retry/retry-tool-results.d.ts.map +1 -0
  60. package/dist/server/cli/retry/retry-tool-results.js +24 -0
  61. package/dist/server/cli/retry/retry-tool-results.js.map +1 -0
  62. package/dist/server/cli/retry/retry-types.d.ts +30 -0
  63. package/dist/server/cli/retry/retry-types.d.ts.map +1 -0
  64. package/dist/server/cli/retry/retry-types.js +4 -0
  65. package/dist/server/cli/retry/retry-types.js.map +1 -0
  66. package/dist/server/index.js +21 -109
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/server/server-setup.d.ts +16 -1
  69. package/dist/server/server-setup.d.ts.map +1 -1
  70. package/dist/server/server-setup.js +107 -0
  71. package/dist/server/server-setup.js.map +1 -1
  72. package/dist/server/services/plan/board-config.d.ts +21 -0
  73. package/dist/server/services/plan/board-config.d.ts.map +1 -0
  74. package/dist/server/services/plan/board-config.js +112 -0
  75. package/dist/server/services/plan/board-config.js.map +1 -0
  76. package/dist/server/services/plan/composer.d.ts +1 -1
  77. package/dist/server/services/plan/composer.d.ts.map +1 -1
  78. package/dist/server/services/plan/composer.js +7 -5
  79. package/dist/server/services/plan/composer.js.map +1 -1
  80. package/dist/server/services/plan/executor.d.ts +48 -48
  81. package/dist/server/services/plan/executor.d.ts.map +1 -1
  82. package/dist/server/services/plan/executor.js +157 -455
  83. package/dist/server/services/plan/executor.js.map +1 -1
  84. package/dist/server/services/plan/issue-loader.d.ts +16 -0
  85. package/dist/server/services/plan/issue-loader.d.ts.map +1 -0
  86. package/dist/server/services/plan/issue-loader.js +46 -0
  87. package/dist/server/services/plan/issue-loader.js.map +1 -0
  88. package/dist/server/services/plan/issue-writer.d.ts +34 -0
  89. package/dist/server/services/plan/issue-writer.d.ts.map +1 -0
  90. package/dist/server/services/plan/issue-writer.js +110 -0
  91. package/dist/server/services/plan/issue-writer.js.map +1 -0
  92. package/dist/server/services/plan/output-manager.d.ts.map +1 -1
  93. package/dist/server/services/plan/output-manager.js +2 -1
  94. package/dist/server/services/plan/output-manager.js.map +1 -1
  95. package/dist/server/services/plan/progress-log.d.ts +11 -0
  96. package/dist/server/services/plan/progress-log.d.ts.map +1 -0
  97. package/dist/server/services/plan/progress-log.js +81 -0
  98. package/dist/server/services/plan/progress-log.js.map +1 -0
  99. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -1
  100. package/dist/server/services/plan/prompt-builder.js +48 -31
  101. package/dist/server/services/plan/prompt-builder.js.map +1 -1
  102. package/dist/server/services/plan/readiness-planner.d.ts +15 -0
  103. package/dist/server/services/plan/readiness-planner.d.ts.map +1 -0
  104. package/dist/server/services/plan/readiness-planner.js +41 -0
  105. package/dist/server/services/plan/readiness-planner.js.map +1 -0
  106. package/dist/server/services/plan/review-gate.d.ts +31 -0
  107. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  108. package/dist/server/services/plan/review-gate.js +52 -2
  109. package/dist/server/services/plan/review-gate.js.map +1 -1
  110. package/dist/server/services/platform.d.ts +56 -0
  111. package/dist/server/services/platform.d.ts.map +1 -1
  112. package/dist/server/services/platform.js +154 -52
  113. package/dist/server/services/platform.js.map +1 -1
  114. package/dist/server/services/websocket/file-download-handler.d.ts +17 -0
  115. package/dist/server/services/websocket/file-download-handler.d.ts.map +1 -0
  116. package/dist/server/services/websocket/file-download-handler.js +165 -0
  117. package/dist/server/services/websocket/file-download-handler.js.map +1 -0
  118. package/dist/server/services/websocket/git-branch-handlers.d.ts +1 -1
  119. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  120. package/dist/server/services/websocket/git-branch-handlers.js +21 -1
  121. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  122. package/dist/server/services/websocket/git-handlers.js +1 -1
  123. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  124. package/dist/server/services/websocket/git-worktree-handlers.d.ts +2 -0
  125. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/git-worktree-handlers.js +30 -4
  127. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/handler-context.d.ts +15 -0
  129. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  130. package/dist/server/services/websocket/handler.d.ts +7 -0
  131. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  132. package/dist/server/services/websocket/handler.js +73 -11
  133. package/dist/server/services/websocket/handler.js.map +1 -1
  134. package/dist/server/services/websocket/msg-id-tracker.d.ts +21 -0
  135. package/dist/server/services/websocket/msg-id-tracker.d.ts.map +1 -0
  136. package/dist/server/services/websocket/msg-id-tracker.js +77 -0
  137. package/dist/server/services/websocket/msg-id-tracker.js.map +1 -0
  138. package/dist/server/services/websocket/quality-handlers.js +15 -3
  139. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  140. package/dist/server/services/websocket/quality-review-agent.js +2 -2
  141. package/dist/server/services/websocket/session-handlers.d.ts +48 -2
  142. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  143. package/dist/server/services/websocket/session-handlers.js +204 -65
  144. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  145. package/dist/server/services/websocket/session-initialization.d.ts +2 -2
  146. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  147. package/dist/server/services/websocket/session-initialization.js +75 -17
  148. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  149. package/dist/server/services/websocket/session-registry.d.ts +29 -1
  150. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  151. package/dist/server/services/websocket/session-registry.js +53 -4
  152. package/dist/server/services/websocket/session-registry.js.map +1 -1
  153. package/dist/server/services/websocket/tab-broadcast.d.ts +24 -0
  154. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -0
  155. package/dist/server/services/websocket/tab-broadcast.js +13 -0
  156. package/dist/server/services/websocket/tab-broadcast.js.map +1 -0
  157. package/dist/server/services/websocket/tab-event-buffer.d.ts +103 -0
  158. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -0
  159. package/dist/server/services/websocket/tab-event-buffer.js +107 -0
  160. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -0
  161. package/dist/server/services/websocket/tab-event-replay.d.ts +20 -0
  162. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -0
  163. package/dist/server/services/websocket/tab-event-replay.js +21 -0
  164. package/dist/server/services/websocket/tab-event-replay.js.map +1 -0
  165. package/dist/server/services/websocket/tab-handlers.d.ts +0 -1
  166. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  167. package/dist/server/services/websocket/tab-handlers.js +2 -9
  168. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  169. package/dist/server/services/websocket/types.d.ts +15 -6
  170. package/dist/server/services/websocket/types.d.ts.map +1 -1
  171. package/dist/server/services/websocket/types.js +6 -4
  172. package/dist/server/services/websocket/types.js.map +1 -1
  173. package/package.json +1 -1
  174. package/server/README.md +1 -1
  175. package/server/cli/headless/claude-invoker-stall.ts +7 -2
  176. package/server/cli/headless/claude-invoker.ts +1 -1
  177. package/server/cli/headless/runner.ts +67 -72
  178. package/server/cli/headless/stall-assessor.ts +9 -4
  179. package/server/cli/headless/types.ts +1 -1
  180. package/server/cli/improvisation-history-store.ts +62 -0
  181. package/server/cli/improvisation-movements.ts +120 -0
  182. package/server/cli/improvisation-output-queue.ts +42 -0
  183. package/server/cli/improvisation-retry.ts +25 -600
  184. package/server/cli/improvisation-session-manager.ts +74 -160
  185. package/server/cli/retry/retry-best-result.ts +70 -0
  186. package/server/cli/retry/retry-context-loss.ts +87 -0
  187. package/server/cli/retry/retry-premature-completion.ts +113 -0
  188. package/server/cli/retry/retry-recovery-strategies.ts +247 -0
  189. package/server/cli/retry/retry-resume-strategy.ts +33 -0
  190. package/server/cli/retry/retry-runner-factory.ts +70 -0
  191. package/server/cli/retry/retry-tool-results.ts +31 -0
  192. package/server/cli/retry/retry-types.ts +32 -0
  193. package/server/index.ts +37 -123
  194. package/server/server-setup.ts +126 -1
  195. package/server/services/plan/agents/assess-stall.md +11 -4
  196. package/server/services/plan/board-config.ts +122 -0
  197. package/server/services/plan/composer.ts +7 -5
  198. package/server/services/plan/executor.ts +214 -467
  199. package/server/services/plan/issue-loader.ts +64 -0
  200. package/server/services/plan/issue-writer.ts +137 -0
  201. package/server/services/plan/output-manager.ts +2 -1
  202. package/server/services/plan/progress-log.ts +92 -0
  203. package/server/services/plan/prompt-builder.ts +73 -35
  204. package/server/services/plan/readiness-planner.ts +50 -0
  205. package/server/services/plan/review-gate.ts +102 -2
  206. package/server/services/platform.ts +163 -58
  207. package/server/services/websocket/file-download-handler.ts +191 -0
  208. package/server/services/websocket/git-branch-handlers.ts +28 -1
  209. package/server/services/websocket/git-handlers.ts +1 -1
  210. package/server/services/websocket/git-worktree-handlers.ts +31 -4
  211. package/server/services/websocket/handler-context.ts +15 -0
  212. package/server/services/websocket/handler.ts +76 -12
  213. package/server/services/websocket/msg-id-tracker.ts +84 -0
  214. package/server/services/websocket/quality-handlers.ts +16 -3
  215. package/server/services/websocket/quality-review-agent.ts +2 -2
  216. package/server/services/websocket/session-handlers.ts +213 -68
  217. package/server/services/websocket/session-initialization.ts +83 -19
  218. package/server/services/websocket/session-registry.ts +61 -4
  219. package/server/services/websocket/tab-broadcast.ts +38 -0
  220. package/server/services/websocket/tab-event-buffer.ts +159 -0
  221. package/server/services/websocket/tab-event-replay.ts +42 -0
  222. package/server/services/websocket/tab-handlers.ts +2 -9
  223. package/server/services/websocket/types.ts +17 -4
@@ -9,41 +9,56 @@
9
9
  * reconciles state, and repeats.
10
10
  *
11
11
  * Implementation is split across focused modules:
12
+ * - board-config.ts — board.md metadata reads, workspace.json active board resolution
12
13
  * - config-installer.ts — tool permissions install/uninstall
13
14
  * - issue-prompt-builder.ts — per-issue prompt construction
15
+ * - issue-writer.ts — issue front-matter updates, recovery, revert, cancellation notes
14
16
  * - output-manager.ts — output path resolution, listing, publishing
15
- * - review-gate.ts AI-powered quality gate (review, parse, persist)
16
- * - front-matter.ts YAML front matter field editing utility
17
+ * - progress-log.ts progress.md writer + output dir creation
18
+ * - review-gate.ts AI-powered quality gate (review, parse, persist, full pipeline)
17
19
  */
18
20
 
19
21
  import { EventEmitter } from 'node:events';
20
- import { existsSync, readFileSync } from 'node:fs';
21
- import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
22
- import { isAbsolute, join, relative, resolve } from 'node:path';
22
+ import { readFile } from 'node:fs/promises';
23
+ import { join } from 'node:path';
23
24
  import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
25
+ import {
26
+ DEFAULT_MAX_PARALLEL_AGENTS,
27
+ getBoardMaxParallelAgents,
28
+ resolveActiveBoardId,
29
+ resolveBoardDir,
30
+ tryCompleteBoardIfDone,
31
+ } from './board-config.js';
24
32
  import { ConfigInstaller } from './config-installer.js';
25
33
  import { resolveReadyToWork } from './dependency-resolver.js';
26
- import { checkAllAcceptanceCriteria, replaceFrontMatterField, setFrontMatterFieldAsync } from './front-matter.js';
34
+ import { loadBoardIssues, loadProjectIssues } from './issue-loader.js';
27
35
  import { buildIssuePrompt } from './issue-prompt-builder.js';
28
36
  import { runIssueWithRetry } from './issue-retry.js';
37
+ import {
38
+ extractIssueStatus,
39
+ recoverStaleIssues,
40
+ revertIncompleteIssues,
41
+ updateIssueFrontMatter,
42
+ validateIssuePath,
43
+ type WarnFn,
44
+ } from './issue-writer.js';
29
45
  import { listExistingDocs, publishOutputs, resolveOutputPath } from './output-manager.js';
30
- import { parseBoardDirectory, parsePlanDirectory, resolvePmDir } from './parser.js';
31
- import { appendReviewFeedback, getReviewAttemptCount, MAX_REVIEW_ATTEMPTS, persistReviewResult, reviewIssue } from './review-gate.js';
46
+ import { resolvePmDir } from './parser.js';
47
+ import { appendProgressEntry, ensureOutputDirs } from './progress-log.js';
48
+ import { buildCompletionReason, detectDeadState, hasBlockedIssues } from './readiness-planner.js';
49
+ import { runReviewPipeline } from './review-gate.js';
32
50
  import { reconcileState } from './state-reconciler.js';
33
51
  import type { Issue } from './types.js';
34
52
 
35
53
  export type ExecutionStatus = 'idle' | 'starting' | 'executing' | 'paused' | 'stopping' | 'complete' | 'error';
36
54
 
37
- /** Default max parallel agents when board doesn't specify. */
38
- const DEFAULT_MAX_PARALLEL_AGENTS = 3;
39
-
40
55
  /** Stop after this many consecutive waves with zero completions. */
41
56
  const MAX_CONSECUTIVE_EMPTY_WAVES = 3;
42
57
 
43
58
  /** Per-issue stall timeouts (ms) — shorter than Agent Teams wave timeouts */
44
59
  const ISSUE_STALL_WARNING_MS = 900_000; // 15 min
45
60
  const ISSUE_STALL_KILL_MS = 1_800_000; // 30 min
46
- const ISSUE_STALL_HARD_CAP_MS = 3_600_000; // 1 hr hard cap
61
+ const ISSUE_STALL_HARD_CAP_MS = 14_400_000; // 4 hr backstop — only fires after stall signals flag the run
47
62
  const ISSUE_STALL_MAX_EXTENSIONS = 10;
48
63
 
49
64
  export interface ExecutionMetrics {
@@ -55,27 +70,44 @@ export interface ExecutionMetrics {
55
70
  currentWaveIds: string[];
56
71
  }
57
72
 
73
+ /** Scope options for a single run. Passed to start(), startBoard(), startEpic(). */
74
+ export interface StartOptions {
75
+ /** Epic file path — restricts execution to issues under that epic. */
76
+ epic?: string;
77
+ /** Board ID to execute (e.g. "BOARD-001"). */
78
+ board?: string;
79
+ /** Optional worktree directory for running agents. PM data is always read from workingDir. */
80
+ executionDir?: string;
81
+ }
82
+
83
+ /**
84
+ * Immutable per-run context resolved when a run starts. Groups paths, scope,
85
+ * and environment so helper methods can read them in one place rather than
86
+ * reaching for scattered fields on the executor.
87
+ */
88
+ interface PlanExecutionContext {
89
+ readonly workingDir: string;
90
+ readonly extraEnv?: Record<string, string>;
91
+ readonly epicScope: string | null;
92
+ readonly boardId: string | null;
93
+ readonly executionDir: string | null;
94
+ readonly pmDir: string | null;
95
+ readonly boardDir: string | null;
96
+ }
97
+
58
98
  export class PlanExecutor extends EventEmitter {
59
99
  private status: ExecutionStatus = 'idle';
60
- private workingDir: string;
100
+ private readonly workingDir: string;
101
+ private readonly extraEnv?: Record<string, string>;
61
102
  private shouldStop = false;
62
103
  private shouldPause = false;
63
104
  /** AbortController for killing running HeadlessRunner processes on stop. */
64
105
  private waveAbortController: AbortController | null = null;
65
- private epicScope: string | null = null;
66
- /** Cached PM directory path — resolved once per start(). */
67
- private pmDir: string | null = null;
68
- /** Board directory path (e.g. /path/.mstro/pm/boards/BOARD-001). Used for outputs, reviews, progress. */
69
- private boardDir: string | null = null;
70
- /** Board ID being executed (e.g. "BOARD-001") */
71
- private boardId: string | null = null;
106
+ /** Resolved context for the current/last run — rebuilt each runStart(). */
107
+ private context: PlanExecutionContext;
108
+ /** Options from the last run; replayed on resume() to preserve scope. */
109
+ private lastStartOptions: StartOptions = {};
72
110
  private configInstaller: ConfigInstaller;
73
- /** Flag to prevent start() from clearing scope set by startBoard/startEpic */
74
- private _scopeSetByCall = false;
75
- /** Extra environment variables forwarded to HeadlessRunner child processes (e.g. API keys) */
76
- private extraEnv?: Record<string, string>;
77
- /** Optional worktree directory for running AI agents. PM data is always read from workingDir. */
78
- private executionDir: string | null = null;
79
111
  private metrics: ExecutionMetrics = {
80
112
  issuesCompleted: 0,
81
113
  issuesAttempted: 0,
@@ -89,59 +121,60 @@ export class PlanExecutor extends EventEmitter {
89
121
  this.workingDir = workingDir;
90
122
  this.extraEnv = options?.extraEnv;
91
123
  this.configInstaller = new ConfigInstaller(workingDir);
92
- }
93
-
94
- private validateIssuePath(issuePath: string, baseDir: string): string {
95
- const resolvedBase = resolve(baseDir);
96
- const resolvedFull = resolve(resolvedBase, issuePath);
97
- const rel = relative(resolvedBase, resolvedFull);
98
- if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
99
- throw new Error(`Invalid issue path: path traversal detected in "${issuePath}"`);
100
- }
101
- return resolvedFull;
124
+ this.context = this.buildContext({});
102
125
  }
103
126
 
104
127
  getStatus(): ExecutionStatus { return this.status; }
105
128
  getMetrics(): ExecutionMetrics { return { ...this.metrics }; }
106
129
 
107
- async startEpic(epicPath: string): Promise<void> {
108
- this.epicScope = epicPath;
109
- this._scopeSetByCall = true;
110
- return this.start();
130
+ startEpic(epicPath: string): Promise<void> {
131
+ return this.runStart({ epic: epicPath });
111
132
  }
112
133
 
113
134
  /** Start execution, optionally scoped to a specific board. */
114
- async startBoard(boardId: string, executionDir?: string): Promise<void> {
115
- this.boardId = boardId;
116
- this.executionDir = executionDir ?? null;
117
- this._scopeSetByCall = true;
118
- return this.start();
135
+ startBoard(boardId: string, executionDir?: string): Promise<void> {
136
+ return this.runStart({ board: boardId, executionDir });
119
137
  }
120
138
 
121
- async start(): Promise<void> {
139
+ start(options: StartOptions = {}): Promise<void> {
140
+ return this.runStart(options);
141
+ }
142
+
143
+ pause(): void { this.shouldPause = true; }
144
+
145
+ stop(): void {
146
+ this.shouldStop = true;
147
+ this.status = 'stopping';
148
+ this.emit('statusChanged', this.status);
149
+ // Kill all running HeadlessRunner processes in the current wave
150
+ this.waveAbortController?.abort();
151
+ }
152
+
153
+ resume(): Promise<void> {
154
+ if (this.status !== 'paused') return Promise.resolve();
155
+ this.shouldPause = false;
156
+ // Replay the options from the previous run to preserve epic/board scope.
157
+ return this.runStart(this.lastStartOptions);
158
+ }
159
+
160
+ // ── Run orchestration ────────────────────────────────────────
161
+
162
+ private async runStart(options: StartOptions): Promise<void> {
122
163
  if (this.status === 'executing' || this.status === 'starting') return;
123
164
 
165
+ this.lastStartOptions = options;
124
166
  this.shouldStop = false;
125
167
  this.shouldPause = false;
126
- // Reset scoping from previous runs unless explicitly set by startBoard/startEpic
127
- if (!this._scopeSetByCall) {
128
- this.epicScope = null;
129
- this.boardId = null;
130
- this.executionDir = null;
131
- }
132
- this._scopeSetByCall = false;
133
168
  this.status = 'starting';
134
169
  this.emit('statusChanged', this.status);
135
170
 
171
+ this.context = this.buildContext(options);
172
+
136
173
  const startTime = Date.now();
137
174
  this.status = 'executing';
138
175
  this.emit('statusChanged', this.status);
139
176
 
140
- this.pmDir = resolvePmDir(this.workingDir);
141
- this.boardDir = this.resolveBoardDir();
142
-
143
- await this.recoverStaleIssues();
144
-
177
+ await this.runStaleRecovery();
145
178
  const stallResult = await this.runWaveLoop();
146
179
 
147
180
  this.metrics.totalDuration = Date.now() - startTime;
@@ -163,10 +196,43 @@ export class PlanExecutor extends EventEmitter {
163
196
  this.emit('statusChanged', this.status);
164
197
  }
165
198
 
199
+ /** Build an immutable execution context from start options. */
200
+ private buildContext(options: StartOptions): PlanExecutionContext {
201
+ const pmDir = resolvePmDir(this.workingDir);
202
+ const boardId = options.board ?? null;
203
+ return {
204
+ workingDir: this.workingDir,
205
+ extraEnv: this.extraEnv,
206
+ epicScope: options.epic ?? null,
207
+ boardId,
208
+ executionDir: options.executionDir ?? null,
209
+ pmDir,
210
+ boardDir: resolveBoardDir(pmDir, boardId),
211
+ };
212
+ }
213
+
214
+ // ── Warning / update helpers bound to the executor's event stream ──
215
+
216
+ /**
217
+ * Forward module-emitted warnings as executor 'output' events so they flow
218
+ * through to the WebSocket broadcast like inline warnings always have.
219
+ */
220
+ private emitWarn: WarnFn = (message, issueId) => {
221
+ this.emit('output', { issueId: issueId ?? 'system', text: message, boardId: this.context.boardId ?? null });
222
+ };
223
+
224
+ private async setIssueStatus(issuePath: string, newStatus: string): Promise<void> {
225
+ const { pmDir } = this.context;
226
+ if (!pmDir) return;
227
+ await updateIssueFrontMatter(pmDir, issuePath, newStatus, this.emitWarn);
228
+ }
229
+
230
+ // ── Wave loop ────────────────────────────────────────────────
231
+
166
232
  /** Run waves until done, paused, stopped, or stalled. */
167
233
  private async runWaveLoop(): Promise<'done' | 'stalled' | 'dead'> {
168
234
  let consecutiveZeroCompletions = 0;
169
- const maxParallel = await this.getBoardMaxParallelAgents();
235
+ const maxParallel = await getBoardMaxParallelAgents(this.context.pmDir, this.effectiveBoardId(), this.emitWarn);
170
236
 
171
237
  while (!this.shouldStop && !this.shouldPause) {
172
238
  const readyIssues = await this.pickReadyIssues();
@@ -187,33 +253,15 @@ export class PlanExecutor extends EventEmitter {
187
253
  return 'done';
188
254
  }
189
255
 
190
- private async hasDeadIssues(): Promise<boolean> {
191
- const pmDir = this.pmDir;
192
- if (!pmDir) return false;
193
- const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
194
- const issues = effectiveBoardId
195
- ? await this.loadBoardIssues(pmDir, effectiveBoardId)
196
- : this.loadProjectIssues();
197
- if (!issues) return false;
198
- const terminalStatuses = new Set(['done', 'cancelled']);
199
- return issues.some(i => i.type !== 'epic' && !terminalStatuses.has(i.status) && i.status !== 'todo');
200
- }
201
-
202
- pause(): void { this.shouldPause = true; }
203
- stop(): void {
204
- this.shouldStop = true;
205
- this.status = 'stopping';
206
- this.emit('statusChanged', this.status);
207
- // Kill all running HeadlessRunner processes in the current wave
208
- this.waveAbortController?.abort();
256
+ private effectiveBoardId(): string | null {
257
+ return this.context.boardId ?? resolveActiveBoardId(this.context.pmDir);
209
258
  }
210
259
 
211
- resume(): Promise<void> {
212
- if (this.status !== 'paused') return Promise.resolve();
213
- this.shouldPause = false;
214
- // Preserve board/epic scope across resume by marking as a scoped call
215
- this._scopeSetByCall = true;
216
- return this.start();
260
+ private async hasDeadIssues(): Promise<boolean> {
261
+ const { pmDir } = this.context;
262
+ if (!pmDir) return false;
263
+ const { issues } = await this.loadScopedIssues(pmDir);
264
+ return issues ? hasBlockedIssues(issues) : false;
217
265
  }
218
266
 
219
267
  // ── Wave execution ───────────────────────────────────────────
@@ -229,15 +277,15 @@ export class PlanExecutor extends EventEmitter {
229
277
  // Create abort controller for this wave — stop() will abort it
230
278
  this.waveAbortController = new AbortController();
231
279
 
232
- await this.ensureOutputDirs();
280
+ await ensureOutputDirs(this.context.pmDir, this.context.boardDir);
233
281
  this.configInstaller.installPermissions();
234
282
 
235
283
  for (const issue of issues) {
236
- await this.updateIssueFrontMatter(issue.path, 'in_progress');
284
+ await this.setIssueStatus(issue.path, 'in_progress');
237
285
  }
238
286
 
239
- const existingDocs = listExistingDocs(this.workingDir, this.boardDir);
240
- const pmDir = this.pmDir;
287
+ const existingDocs = listExistingDocs(this.workingDir, this.context.boardDir);
288
+ const { pmDir } = this.context;
241
289
 
242
290
  let completedCount = 0;
243
291
 
@@ -264,7 +312,7 @@ export class PlanExecutor extends EventEmitter {
264
312
  issueIds: waveIds,
265
313
  error: error instanceof Error ? error.message : String(error),
266
314
  });
267
- await this.revertIncompleteIssues(issues);
315
+ if (pmDir) await revertIncompleteIssues(pmDir, issues, this.emitWarn);
268
316
  } finally {
269
317
  this.configInstaller.uninstallPermissions();
270
318
  }
@@ -283,18 +331,19 @@ export class PlanExecutor extends EventEmitter {
283
331
  waveLabel: string,
284
332
  abortSignal?: AbortSignal,
285
333
  ): Promise<void> {
286
- const effectiveDir = this.executionDir || this.workingDir;
287
- const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
334
+ const { executionDir, boardDir, workingDir } = this.context;
335
+ const effectiveDir = executionDir || workingDir;
336
+ const outputPath = resolveOutputPath(issue, workingDir, boardDir);
288
337
  const prompt = buildIssuePrompt({
289
338
  issue,
290
339
  workingDir: effectiveDir,
291
340
  pmDir,
292
- boardDir: this.boardDir,
341
+ boardDir,
293
342
  existingDocs,
294
343
  outputPath,
295
344
  });
296
345
 
297
- const boardLogDir = this.boardDir ? join(this.boardDir, 'logs') : undefined;
346
+ const boardLogDir = boardDir ? join(boardDir, 'logs') : undefined;
298
347
  const result = await runWithFileLogger(`pm-issue-${issue.id}`, () => runIssueWithRetry({
299
348
  workingDir: effectiveDir,
300
349
  prompt,
@@ -319,8 +368,10 @@ export class PlanExecutor extends EventEmitter {
319
368
  * doesn't prevent the others or kill the while loop in start().
320
369
  */
321
370
  private async finalizeWave(issues: Issue[], waveStart: number, waveLabel: string): Promise<void> {
371
+ const { pmDir, boardDir, boardId } = this.context;
372
+
322
373
  try {
323
- reconcileState(this.workingDir, this.boardId ?? undefined);
374
+ reconcileState(this.workingDir, boardId ?? undefined);
324
375
  this.emit('stateUpdated');
325
376
  } catch (err) {
326
377
  this.emit('output', {
@@ -330,7 +381,7 @@ export class PlanExecutor extends EventEmitter {
330
381
  }
331
382
 
332
383
  try {
333
- publishOutputs(issues, this.workingDir, this.boardDir, {
384
+ publishOutputs(issues, this.workingDir, boardDir, {
334
385
  onWarning: (issueId, text) => this.emit('output', { issueId, text: `Warning: ${text}` }),
335
386
  });
336
387
  } catch (err) {
@@ -341,7 +392,7 @@ export class PlanExecutor extends EventEmitter {
341
392
  }
342
393
 
343
394
  try {
344
- await this.appendProgressEntry(issues, waveStart);
395
+ await appendProgressEntry(pmDir, boardDir, issues, waveStart, this.emitWarn);
345
396
  } catch (err) {
346
397
  this.emit('output', {
347
398
  issueId: waveLabel,
@@ -358,30 +409,21 @@ export class PlanExecutor extends EventEmitter {
358
409
  * and either confirmed `done` (passed) or reverted to `todo` (failed).
359
410
  */
360
411
  private async reconcileWaveResults(issues: Issue[]): Promise<number> {
361
- const pmDir = this.pmDir;
412
+ const { pmDir } = this.context;
362
413
  if (!pmDir) return 0;
363
414
 
364
415
  let completed = 0;
365
416
 
366
417
  for (const issue of issues) {
367
- const fullPath = this.validateIssuePath(issue.path, pmDir);
418
+ const fullPath = validateIssuePath(issue.path, pmDir);
368
419
  try {
369
420
  const content = await readFile(fullPath, 'utf-8');
370
- const statusMatch = content.match(/^status:\s*(\S+)/m);
371
- const currentStatus = statusMatch?.[1] ?? 'unknown';
421
+ const currentStatus = extractIssueStatus(content) ?? 'unknown';
372
422
 
373
423
  if (currentStatus === 'in_review' || currentStatus === 'done') {
374
- if (issue.reviewGate === 'none') {
375
- // Skip review gate — mark done directly
376
- await this.updateIssueFrontMatter(issue.path, 'done');
377
- this.metrics.issuesCompleted++;
378
- this.emit('issueCompleted', issue);
379
- completed++;
380
- } else {
381
- completed += await this.runReviewGate(issue, pmDir);
382
- }
424
+ if (await this.finalizeCompletedIssue(issue, pmDir)) completed++;
383
425
  } else {
384
- await this.updateIssueFrontMatter(issue.path, issue.status);
426
+ await this.setIssueStatus(issue.path, issue.status);
385
427
  this.emit('issueError', {
386
428
  issueId: issue.id,
387
429
  error: 'Issue did not complete during wave execution',
@@ -395,56 +437,41 @@ export class PlanExecutor extends EventEmitter {
395
437
  return completed;
396
438
  }
397
439
 
398
- /** Run the review gate for a single issue that agents marked as done. Returns 1 if passed, 0 otherwise. */
399
- private async runReviewGate(issue: Issue, pmDir: string): Promise<number> {
400
- const reviewDir = this.boardDir ?? pmDir;
401
- const attempts = getReviewAttemptCount(reviewDir, issue);
402
- if (attempts >= MAX_REVIEW_ATTEMPTS) {
403
- await this.updateIssueFrontMatter(issue.path, 'cancelled');
404
- await this.appendCancellationNote(issue, pmDir, `Cancelled after ${MAX_REVIEW_ATTEMPTS} failed reviews — issue may need restructuring`);
405
- this.emit('reviewProgress', { issueId: issue.id, status: 'max_attempts' });
406
- this.emit('issueAbandoned', {
407
- issueId: issue.id,
408
- reason: `Review failed ${MAX_REVIEW_ATTEMPTS} times — cancelled to unblock dependents`,
409
- attempts,
410
- });
411
- this.emit('output', { issueId: issue.id, text: `Review: max attempts reached, cancelling issue to unblock dependents` });
412
- return 0;
413
- }
414
-
415
- await this.updateIssueFrontMatter(issue.path, 'in_review');
416
- this.emit('reviewProgress', { issueId: issue.id, status: 'reviewing' });
417
-
418
- const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
419
- const result = await reviewIssue({
420
- workingDir: this.executionDir || this.workingDir,
421
- issue,
422
- pmDir,
423
- outputPath,
424
- onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
425
- logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
426
- reviewCriteria: await this.getBoardReviewCriteria(),
427
- boardDir: this.boardDir,
428
- extraEnv: this.extraEnv,
429
- });
430
- persistReviewResult(reviewDir, issue, result);
431
-
432
- if (result.passed) {
433
- await this.updateIssueFrontMatter(issue.path, 'done');
440
+ /**
441
+ * Finalize a single issue whose status reached `in_review`/`done`. Runs the
442
+ * review pipeline unless the issue opted out via `reviewGate: 'none'`.
443
+ * Returns true when the issue is confirmed done (counted toward completions).
444
+ */
445
+ private async finalizeCompletedIssue(issue: Issue, pmDir: string): Promise<boolean> {
446
+ if (issue.reviewGate === 'none') {
447
+ await this.setIssueStatus(issue.path, 'done');
434
448
  this.metrics.issuesCompleted++;
435
- this.emit('reviewProgress', { issueId: issue.id, status: 'passed' });
436
449
  this.emit('issueCompleted', issue);
437
- return 1;
438
- }
439
-
440
- await this.updateIssueFrontMatter(issue.path, 'todo');
441
- appendReviewFeedback(pmDir, issue, result);
442
- this.emit('reviewProgress', { issueId: issue.id, status: 'failed' });
443
- this.emit('issueError', {
444
- issueId: issue.id,
445
- error: `Review failed: ${result.checks.filter(c => !c.passed).map(c => c.name).join(', ')}`,
446
- });
447
- return 0;
450
+ return true;
451
+ }
452
+
453
+ const passed = await runReviewPipeline(
454
+ {
455
+ issue,
456
+ pmDir,
457
+ workingDir: this.workingDir,
458
+ executionDir: this.context.executionDir,
459
+ boardDir: this.context.boardDir,
460
+ boardId: this.context.boardId,
461
+ extraEnv: this.extraEnv,
462
+ },
463
+ {
464
+ setStatus: (path, status) => this.setIssueStatus(path, status),
465
+ onOutput: (issueId, text) => this.emit('output', { issueId, text }),
466
+ onReviewProgress: (issueId, status) => this.emit('reviewProgress', { issueId, status }),
467
+ onIssueAbandoned: (issueId, reason, attempts) => this.emit('issueAbandoned', { issueId, reason, attempts }),
468
+ onIssueCompleted: (completedIssue) => this.emit('issueCompleted', completedIssue),
469
+ onIssueError: (issueId, error) => this.emit('issueError', { issueId, error }),
470
+ warn: this.emitWarn,
471
+ },
472
+ );
473
+ if (passed) this.metrics.issuesCompleted++;
474
+ return passed;
448
475
  }
449
476
 
450
477
  // ── Recovery ─────────────────────────────────────────────────
@@ -455,28 +482,14 @@ export class PlanExecutor extends EventEmitter {
455
482
  * these issues block the dependency graph and cause the executor to
456
483
  * find zero ready issues, making "Implement" appear to do nothing.
457
484
  */
458
- private async recoverStaleIssues(): Promise<void> {
459
- const pmDir = this.pmDir;
485
+ private async runStaleRecovery(): Promise<void> {
486
+ const { pmDir } = this.context;
460
487
  if (!pmDir) return;
461
488
 
462
- const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
463
- const issues = effectiveBoardId
464
- ? await this.loadBoardIssues(pmDir, effectiveBoardId)
465
- : this.loadProjectIssues();
466
-
489
+ const { issues } = await this.loadScopedIssues(pmDir);
467
490
  if (!issues) return;
468
491
 
469
- const staleStatuses = new Set(['in_progress', 'in_review']);
470
- const recovered: string[] = [];
471
-
472
- for (const issue of issues) {
473
- if (issue.type === 'epic') continue;
474
- if (staleStatuses.has(issue.status)) {
475
- await this.updateIssueFrontMatter(issue.path, 'todo');
476
- recovered.push(`${issue.id} (${issue.status} → todo)`);
477
- }
478
- }
479
-
492
+ const recovered = await recoverStaleIssues(pmDir, issues, this.emitWarn);
480
493
  if (recovered.length > 0) {
481
494
  this.emit('output', {
482
495
  issueId: 'recovery',
@@ -486,315 +499,49 @@ export class PlanExecutor extends EventEmitter {
486
499
  }
487
500
  }
488
501
 
489
- // ── Helpers ──────────────────────────────────────────────────
490
-
491
- /** Read the board's maxParallelAgents setting, falling back to default. */
492
- private async getBoardMaxParallelAgents(): Promise<number> {
493
- const pmDir = this.pmDir;
494
- if (!pmDir) return DEFAULT_MAX_PARALLEL_AGENTS;
495
-
496
- const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
497
- if (!effectiveBoardId) return DEFAULT_MAX_PARALLEL_AGENTS;
498
-
499
- const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
500
- if (!existsSync(boardMdPath)) return DEFAULT_MAX_PARALLEL_AGENTS;
501
-
502
- try {
503
- const content = await readFile(boardMdPath, 'utf-8');
504
- const match = content.match(/^max_parallel_agents:\s*(\d+)/m);
505
- return match ? Math.max(1, Math.min(Number(match[1]), 10)) : DEFAULT_MAX_PARALLEL_AGENTS;
506
- } catch (err) {
507
- this.emit('output', { issueId: 'system', text: `Warning: failed to read board max_parallel_agents: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
508
- return DEFAULT_MAX_PARALLEL_AGENTS;
509
- }
510
- }
511
-
512
- /** Read the board's custom review criteria, if set. */
513
- private async getBoardReviewCriteria(): Promise<string | undefined> {
514
- const pmDir = this.pmDir;
515
- if (!pmDir) return undefined;
516
-
517
- const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
518
- if (!effectiveBoardId) return undefined;
519
-
520
- const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
521
- if (!existsSync(boardMdPath)) return undefined;
522
-
523
- try {
524
- const content = await readFile(boardMdPath, 'utf-8');
525
- const match = content.match(/^review_criteria:\s*"(.+)"/m);
526
- if (!match) return undefined;
527
- const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
528
- return raw || undefined;
529
- } catch (err) {
530
- this.emit('output', { issueId: 'system', text: `Warning: failed to read board review criteria: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
531
- return undefined;
532
- }
533
- }
502
+ // ── Issue loading & readiness ────────────────────────────────
534
503
 
535
504
  private async pickReadyIssues(): Promise<Issue[]> {
536
- const pmDir = this.pmDir;
505
+ const { pmDir, epicScope } = this.context;
537
506
  if (!pmDir) {
538
507
  this.emit('error', 'No PM directory found');
539
508
  return [];
540
509
  }
541
510
 
542
- const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
543
- const issues = effectiveBoardId
544
- ? await this.loadBoardIssues(pmDir, effectiveBoardId)
545
- : this.loadProjectIssues();
546
-
511
+ const { issues, boardId } = await this.loadScopedIssues(pmDir);
547
512
  if (!issues) return [];
548
513
 
549
- const readyIssues = resolveReadyToWork(issues, this.epicScope ?? undefined);
514
+ const readyIssues = resolveReadyToWork(issues, epicScope ?? undefined);
550
515
  if (readyIssues.length === 0) {
551
- const deadState = this.detectDeadState(issues);
516
+ const deadState = detectDeadState(issues);
552
517
  if (deadState) {
553
518
  this.emit('error', deadState);
554
519
  } else {
555
- this.emit('complete', this.buildCompletionReason(issues));
556
- if (effectiveBoardId) {
557
- await this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
520
+ this.emit('complete', buildCompletionReason(issues, epicScope));
521
+ if (boardId) {
522
+ await tryCompleteBoardIfDone(pmDir, boardId, issues, this.emitWarn);
558
523
  }
559
524
  }
560
525
  }
561
526
  return readyIssues;
562
527
  }
563
528
 
564
- /** Load issues from a specific board, auto-activating draft boards. Returns null on error. */
565
- private async loadBoardIssues(pmDir: string, boardId: string): Promise<Issue[] | null> {
566
- const boardState = parseBoardDirectory(pmDir, boardId);
567
- if (!boardState) {
568
- this.emit('error', `Board not found: ${boardId}`);
569
- return null;
570
- }
571
- if (boardState.state.paused) {
572
- this.emit('error', 'Board is paused');
573
- return null;
574
- }
575
- if (boardState.board.status === 'draft') {
576
- await this.activateBoard(pmDir, boardId);
577
- } else if (boardState.board.status !== 'active') {
578
- this.emit('error', `Board ${boardId} is not active (status: ${boardState.board.status})`);
579
- return null;
580
- }
581
- return boardState.issues;
582
- }
583
-
584
- /** Load project-level issues (legacy or no boards). Returns null on error. */
585
- private loadProjectIssues(): Issue[] | null {
586
- const fullState = parsePlanDirectory(this.workingDir);
587
- if (!fullState) {
588
- this.emit('error', 'No PM directory found');
589
- return null;
590
- }
591
- if (fullState.state.paused) {
592
- this.emit('error', 'Project is paused');
593
- return null;
594
- }
595
- return fullState.issues;
596
- }
597
-
598
- /** Activate a draft board by updating its status in board.md. */
599
- private async activateBoard(pmDir: string, boardId: string): Promise<void> {
600
- const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
601
- if (!existsSync(boardMdPath)) return;
602
- try {
603
- const content = await readFile(boardMdPath, 'utf-8');
604
- await writeFile(boardMdPath, replaceFrontMatterField(content, 'status', 'active'), 'utf-8');
605
- } catch (err) {
606
- this.emit('output', { issueId: 'system', text: `Warning: failed to activate board ${boardId}: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
607
- }
608
- }
609
-
610
- /** Check if all issues in a board are done and mark board as completed. */
611
- private async tryCompleteBoardIfDone(pmDir: string, boardId: string, issues: Issue[]): Promise<void> {
612
- const allDone = issues.length > 0 && issues.every(i => i.status === 'done' || i.status === 'cancelled');
613
- if (!allDone) return;
614
-
615
- const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
616
- if (!existsSync(boardMdPath)) return;
617
-
618
- try {
619
- let content = await readFile(boardMdPath, 'utf-8');
620
- content = replaceFrontMatterField(content, 'status', 'completed');
621
- content = replaceFrontMatterField(content, 'completed_at', `"${new Date().toISOString()}"`);
622
- await writeFile(boardMdPath, content, 'utf-8');
623
- } catch (err) {
624
- this.emit('output', { issueId: 'system', text: `Warning: failed to mark board ${boardId} as completed: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
625
- }
626
- }
627
-
628
- private resolveActiveBoardId(): string | null {
629
- const pmDir = this.pmDir;
630
- if (!pmDir) return null;
631
- try {
632
- const workspacePath = join(pmDir, 'workspace.json');
633
- if (!existsSync(workspacePath)) return null;
634
- const workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
635
- return workspace.activeBoardId ?? null;
636
- } catch {
637
- return null;
638
- }
639
- }
640
-
641
- private buildCompletionReason(issues: Issue[]): string {
642
- const nonEpic = issues.filter(i => i.type !== 'epic');
643
- const done = nonEpic.filter(i => i.status === 'done' || i.status === 'cancelled').length;
644
- const blocked = nonEpic.filter(i => i.status === 'todo').length;
645
- if (done === nonEpic.length) return this.epicScope ? 'All epic issues are done' : 'All issues are done';
646
- if (blocked > 0) return `${done}/${nonEpic.length} issues done, ${blocked} blocked by incomplete dependencies`;
647
- return this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
648
- }
649
-
650
- /** Detect issues stuck in non-terminal states with no path to completion. */
651
- private detectDeadState(issues: Issue[]): string | null {
652
- const nonEpic = issues.filter(i => i.type !== 'epic');
653
- const terminalStatuses = new Set(['done', 'cancelled']);
654
- const stuck = nonEpic.filter(i => !terminalStatuses.has(i.status) && i.status !== 'todo');
655
-
656
- if (stuck.length === 0) return null;
657
-
658
- const stuckIds = stuck.map(i => `${i.id} (${i.status})`).join(', ');
659
-
660
- const issueByPath = new Map(issues.map(i => [i.path, i]));
661
- const blockedByStuck = nonEpic.filter(i => {
662
- if (i.status !== 'todo') return false;
663
- return i.blockedBy.some(bp => {
664
- const blocker = issueByPath.get(bp);
665
- return blocker && !terminalStatuses.has(blocker.status);
666
- });
667
- });
668
- const blockedIds = blockedByStuck.map(i => i.id).join(', ');
669
-
670
- return `Board stuck: ${stuckIds} cannot progress${blockedIds ? `. Blocking: ${blockedIds}` : ''}`;
671
- }
672
-
673
- private async revertIncompleteIssues(issues: Issue[]): Promise<void> {
674
- const pmDir = this.pmDir;
675
- if (!pmDir) return;
676
- for (const issue of issues) {
677
- const fullPath = this.validateIssuePath(issue.path, pmDir);
678
- try {
679
- const content = await readFile(fullPath, 'utf-8');
680
- if (content.match(/^status:\s*in_progress$/m)) {
681
- await this.updateIssueFrontMatter(issue.path, issue.status);
682
- }
683
- } catch (err) {
684
- this.emit('output', { issueId: issue.id, text: `Warning: failed to revert issue status: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
685
- }
686
- }
687
- }
688
-
689
- private async appendCancellationNote(issue: Issue, pmDir: string, reason: string): Promise<void> {
690
- const fullPath = this.validateIssuePath(issue.path, pmDir);
691
- try {
692
- let content = await readFile(fullPath, 'utf-8');
693
- const entry = `- Cancelled (${new Date().toISOString().split('T')[0]}): ${reason}`;
694
- if (content.includes('## Activity')) {
695
- content = content.replace(/## Activity/, `## Activity\n${entry}`);
696
- } else {
697
- content += `\n\n## Activity\n${entry}`;
698
- }
699
- await writeFile(fullPath, content, 'utf-8');
700
- } catch (err) {
701
- this.emit('output', { issueId: issue.id, text: `Warning: failed to append cancellation note: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
702
- }
703
- }
704
-
705
- private async updateIssueFrontMatter(issuePath: string, newStatus: string): Promise<void> {
706
- const pmDir = this.pmDir;
707
- if (!pmDir) return;
708
- try {
709
- const fullPath = this.validateIssuePath(issuePath, pmDir);
710
- await setFrontMatterFieldAsync(fullPath, 'status', newStatus);
711
-
712
- if (newStatus === 'done') {
713
- const content = await readFile(fullPath, 'utf-8');
714
- const updated = checkAllAcceptanceCriteria(content);
715
- if (updated !== content) await writeFile(fullPath, updated, 'utf-8');
716
- }
717
- } catch (err) {
718
- this.emit('output', { issueId: 'system', text: `Warning: failed to update issue front matter for ${issuePath}: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
719
- }
720
- }
721
-
722
- private async ensureOutputDirs(): Promise<void> {
723
- if (this.boardDir) {
724
- const boardOutDir = join(this.boardDir, 'out');
725
- if (!existsSync(boardOutDir)) await mkdir(boardOutDir, { recursive: true });
726
- } else {
727
- const pmDir = this.pmDir;
728
- if (pmDir) {
729
- const outDir = join(pmDir, 'out');
730
- if (!existsSync(outDir)) await mkdir(outDir, { recursive: true });
731
- }
732
- }
733
- }
734
-
735
- private async appendProgressEntry(issues: Issue[], waveStart: number): Promise<void> {
736
- const pmDir = this.pmDir;
737
- if (!pmDir) return;
738
-
739
- const progressPath = this.boardDir
740
- ? join(this.boardDir, 'progress.md')
741
- : join(pmDir, 'progress.md');
742
-
743
- const durationMin = Math.round((Date.now() - waveStart) / 60_000);
744
- const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 16);
745
-
746
- const completed: string[] = [];
747
- const failed: string[] = [];
748
- for (const issue of issues) {
749
- try {
750
- const content = await readFile(this.validateIssuePath(issue.path, pmDir), 'utf-8');
751
- const statusMatch = content.match(/^status:\s*(\S+)/m);
752
- if (statusMatch?.[1] === 'done') {
753
- completed.push(issue.id);
754
- } else {
755
- failed.push(issue.id);
756
- }
757
- } catch {
758
- failed.push(issue.id);
759
- }
760
- }
761
-
762
- const lines = [
763
- '',
764
- `## ${timestamp} — Wave [${issues.map(i => i.id).join(', ')}]`,
765
- '',
766
- `- **Duration**: ${durationMin} min`,
767
- `- **Completed**: ${completed.length}/${issues.length}${completed.length > 0 ? ` (${completed.join(', ')})` : ''}`,
768
- ];
769
- if (failed.length > 0) {
770
- lines.push(`- **Failed**: ${failed.join(', ')}`);
771
- }
772
- lines.push('');
773
-
774
- await this.writeProgressLines(progressPath, lines);
775
- }
776
-
777
- private async writeProgressLines(filePath: string, lines: string[]): Promise<void> {
778
- try {
779
- if (existsSync(filePath)) {
780
- await appendFile(filePath, `\n${lines.join('\n')}`, 'utf-8');
781
- } else {
782
- await writeFile(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
783
- }
784
- } catch (err) {
785
- this.emit('output', { issueId: 'system', text: `Warning: failed to write progress log: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
786
- }
787
- }
788
-
789
- /** Resolve the active board's directory path for outputs, reviews, and progress. */
790
- private resolveBoardDir(): string | null {
791
- const pmDir = this.pmDir;
792
- if (!pmDir) return null;
793
-
794
- const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
795
- if (!effectiveBoardId) return null;
796
-
797
- const boardDir = join(pmDir, 'boards', effectiveBoardId);
798
- return existsSync(boardDir) ? boardDir : null;
529
+ /**
530
+ * Load issues for the active execution scope. Returns the resolved boardId
531
+ * alongside the issues so callers can branch on board-specific logic without
532
+ * re-resolving the scope.
533
+ */
534
+ private async loadScopedIssues(pmDir: string): Promise<{ issues: Issue[] | null; boardId: string | null }> {
535
+ const boardId = this.effectiveBoardId();
536
+ const issues = boardId
537
+ ? await loadBoardIssues(pmDir, boardId, {
538
+ onError: msg => this.emit('error', msg),
539
+ warn: this.emitWarn,
540
+ })
541
+ : loadProjectIssues(this.workingDir, { onError: msg => this.emit('error', msg) });
542
+ return { issues, boardId };
799
543
  }
800
544
  }
545
+
546
+ // Re-export for backwards compatibility with modules that imported the constant from here.
547
+ export { DEFAULT_MAX_PARALLEL_AGENTS };