mstro-app 0.4.39 → 0.4.44

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 (202) hide show
  1. package/PRIVACY.md +1 -3
  2. package/bin/commands/login.js +17 -7
  3. package/bin/commands/logout.js +14 -6
  4. package/bin/commands/status.js +9 -3
  5. package/bin/commands/whoami.js +10 -4
  6. package/bin/mstro.js +11 -1
  7. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  8. package/dist/server/cli/headless/claude-invoker-stream.js +1 -0
  9. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  10. package/dist/server/cli/headless/index.d.ts +1 -0
  11. package/dist/server/cli/headless/index.d.ts.map +1 -1
  12. package/dist/server/cli/headless/index.js +2 -0
  13. package/dist/server/cli/headless/index.js.map +1 -1
  14. package/dist/server/cli/headless/resilient-runner.d.ts +47 -0
  15. package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -0
  16. package/dist/server/cli/headless/resilient-runner.js +234 -0
  17. package/dist/server/cli/headless/resilient-runner.js.map +1 -0
  18. package/dist/server/cli/headless/retry-strategies.d.ts +44 -0
  19. package/dist/server/cli/headless/retry-strategies.d.ts.map +1 -0
  20. package/dist/server/cli/headless/retry-strategies.js +262 -0
  21. package/dist/server/cli/headless/retry-strategies.js.map +1 -0
  22. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  23. package/dist/server/cli/headless/stall-assessor.js +5 -0
  24. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  25. package/dist/server/cli/headless/tool-watchdog.d.ts +2 -0
  26. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  27. package/dist/server/cli/headless/tool-watchdog.js +31 -4
  28. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  29. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  30. package/dist/server/cli/improvisation-retry.js +1 -30
  31. package/dist/server/cli/improvisation-retry.js.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
  33. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  34. package/dist/server/cli/improvisation-session-manager.js +16 -3
  35. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  36. package/dist/server/cli/prompt-builders.d.ts.map +1 -1
  37. package/dist/server/cli/prompt-builders.js +31 -13
  38. package/dist/server/cli/prompt-builders.js.map +1 -1
  39. package/dist/server/index.js +1 -9
  40. package/dist/server/index.js.map +1 -1
  41. package/dist/server/mcp/bouncer-cli.js +5 -4
  42. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  43. package/dist/server/mcp/bouncer-haiku.js +1 -1
  44. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  45. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  46. package/dist/server/mcp/bouncer-integration.js +14 -8
  47. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  48. package/dist/server/mcp/security-patterns.js +1 -1
  49. package/dist/server/mcp/security-patterns.js.map +1 -1
  50. package/dist/server/services/plan/composer.d.ts.map +1 -1
  51. package/dist/server/services/plan/composer.js +19 -9
  52. package/dist/server/services/plan/composer.js.map +1 -1
  53. package/dist/server/services/plan/executor.d.ts +6 -1
  54. package/dist/server/services/plan/executor.d.ts.map +1 -1
  55. package/dist/server/services/plan/executor.js +163 -77
  56. package/dist/server/services/plan/executor.js.map +1 -1
  57. package/dist/server/services/plan/front-matter.d.ts +1 -0
  58. package/dist/server/services/plan/front-matter.d.ts.map +1 -1
  59. package/dist/server/services/plan/front-matter.js +6 -0
  60. package/dist/server/services/plan/front-matter.js.map +1 -1
  61. package/dist/server/services/plan/issue-classification.d.ts +11 -0
  62. package/dist/server/services/plan/issue-classification.d.ts.map +1 -0
  63. package/dist/server/services/plan/issue-classification.js +20 -0
  64. package/dist/server/services/plan/issue-classification.js.map +1 -0
  65. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  66. package/dist/server/services/plan/issue-prompt-builder.js +7 -4
  67. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  68. package/dist/server/services/plan/issue-retry.d.ts +0 -5
  69. package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
  70. package/dist/server/services/plan/issue-retry.js +12 -241
  71. package/dist/server/services/plan/issue-retry.js.map +1 -1
  72. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  73. package/dist/server/services/plan/parser-core.js +1 -0
  74. package/dist/server/services/plan/parser-core.js.map +1 -1
  75. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  76. package/dist/server/services/plan/review-gate.js +9 -6
  77. package/dist/server/services/plan/review-gate.js.map +1 -1
  78. package/dist/server/services/plan/types.d.ts +1 -0
  79. package/dist/server/services/plan/types.d.ts.map +1 -1
  80. package/dist/server/services/platform-credentials.d.ts.map +1 -1
  81. package/dist/server/services/platform-credentials.js +11 -4
  82. package/dist/server/services/platform-credentials.js.map +1 -1
  83. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  84. package/dist/server/services/terminal/pty-manager.js +7 -1
  85. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  86. package/dist/server/services/websocket/file-search-handlers.d.ts.map +1 -1
  87. package/dist/server/services/websocket/file-search-handlers.js +4 -0
  88. package/dist/server/services/websocket/file-search-handlers.js.map +1 -1
  89. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  90. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  91. package/dist/server/services/websocket/handler.d.ts +2 -0
  92. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  93. package/dist/server/services/websocket/handler.js +18 -7
  94. package/dist/server/services/websocket/handler.js.map +1 -1
  95. package/dist/server/services/websocket/plan-execution-handlers.js +6 -6
  96. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  97. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
  98. package/dist/server/services/websocket/quality-fix-agent.js +90 -42
  99. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
  100. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  101. package/dist/server/services/websocket/quality-handlers.js +48 -7
  102. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  103. package/dist/server/services/websocket/quality-persistence.d.ts +22 -0
  104. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  105. package/dist/server/services/websocket/quality-persistence.js +48 -1
  106. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  107. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  108. package/dist/server/services/websocket/quality-review-agent.js +74 -32
  109. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  110. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  111. package/dist/server/services/websocket/quality-tools.js +18 -18
  112. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  113. package/dist/server/services/websocket/skill-handlers.d.ts +3 -1
  114. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  115. package/dist/server/services/websocket/skill-handlers.js +52 -41
  116. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  117. package/dist/server/services/websocket/skill-watcher.d.ts +17 -0
  118. package/dist/server/services/websocket/skill-watcher.d.ts.map +1 -0
  119. package/dist/server/services/websocket/skill-watcher.js +85 -0
  120. package/dist/server/services/websocket/skill-watcher.js.map +1 -0
  121. package/dist/server/services/websocket/types.d.ts +2 -268
  122. package/dist/server/services/websocket/types.d.ts.map +1 -1
  123. package/dist/server/services/websocket/types.js +0 -4
  124. package/dist/server/services/websocket/types.js.map +1 -1
  125. package/package.json +1 -1
  126. package/server/cli/headless/claude-invoker-stream.ts +1 -0
  127. package/server/cli/headless/index.ts +2 -0
  128. package/server/cli/headless/resilient-runner.ts +354 -0
  129. package/server/cli/headless/retry-strategies.ts +330 -0
  130. package/server/cli/headless/stall-assessor.ts +5 -0
  131. package/server/cli/headless/tool-watchdog.ts +40 -4
  132. package/server/cli/improvisation-retry.ts +1 -32
  133. package/server/cli/improvisation-session-manager.ts +17 -3
  134. package/server/cli/prompt-builders.ts +33 -12
  135. package/server/index.ts +1 -9
  136. package/server/mcp/bouncer-cli.ts +5 -4
  137. package/server/mcp/bouncer-haiku.ts +1 -1
  138. package/server/mcp/bouncer-integration.ts +15 -8
  139. package/server/mcp/security-patterns.ts +1 -1
  140. package/server/services/plan/agents/code-review.md +109 -0
  141. package/server/services/plan/agents/commit-message.md +26 -0
  142. package/server/services/plan/agents/fix-quality.md +24 -0
  143. package/server/services/plan/agents/pr-description.md +28 -0
  144. package/server/services/plan/composer.ts +20 -9
  145. package/server/services/plan/executor.ts +165 -77
  146. package/server/services/plan/front-matter.ts +7 -0
  147. package/server/services/plan/issue-classification.ts +21 -0
  148. package/server/services/plan/issue-prompt-builder.ts +8 -4
  149. package/server/services/plan/issue-retry.ts +15 -330
  150. package/server/services/plan/parser-core.ts +1 -0
  151. package/server/services/plan/review-gate.ts +9 -6
  152. package/server/services/plan/types.ts +3 -0
  153. package/server/services/platform-credentials.ts +10 -4
  154. package/server/services/terminal/pty-manager.ts +7 -1
  155. package/server/services/websocket/file-search-handlers.ts +2 -0
  156. package/server/services/websocket/handler-context.ts +2 -0
  157. package/server/services/websocket/handler.ts +18 -8
  158. package/server/services/websocket/plan-execution-handlers.ts +7 -7
  159. package/server/services/websocket/quality-fix-agent.ts +86 -44
  160. package/server/services/websocket/quality-handlers.ts +48 -7
  161. package/server/services/websocket/quality-persistence.ts +75 -1
  162. package/server/services/websocket/quality-review-agent.ts +70 -31
  163. package/server/services/websocket/quality-tools.ts +16 -14
  164. package/server/services/websocket/skill-handlers.ts +50 -40
  165. package/server/services/websocket/skill-watcher.ts +79 -0
  166. package/server/services/websocket/types.ts +0 -311
  167. package/dist/server/services/deploy/ai-broker.d.ts +0 -63
  168. package/dist/server/services/deploy/ai-broker.d.ts.map +0 -1
  169. package/dist/server/services/deploy/ai-broker.js +0 -360
  170. package/dist/server/services/deploy/ai-broker.js.map +0 -1
  171. package/dist/server/services/deploy/board-execution-handler.d.ts +0 -114
  172. package/dist/server/services/deploy/board-execution-handler.d.ts.map +0 -1
  173. package/dist/server/services/deploy/board-execution-handler.js +0 -621
  174. package/dist/server/services/deploy/board-execution-handler.js.map +0 -1
  175. package/dist/server/services/deploy/credentials.d.ts +0 -35
  176. package/dist/server/services/deploy/credentials.d.ts.map +0 -1
  177. package/dist/server/services/deploy/credentials.js +0 -177
  178. package/dist/server/services/deploy/credentials.js.map +0 -1
  179. package/dist/server/services/deploy/deploy-ai-service.d.ts +0 -107
  180. package/dist/server/services/deploy/deploy-ai-service.d.ts.map +0 -1
  181. package/dist/server/services/deploy/deploy-ai-service.js +0 -294
  182. package/dist/server/services/deploy/deploy-ai-service.js.map +0 -1
  183. package/dist/server/services/deploy/headless-session-handler.d.ts +0 -94
  184. package/dist/server/services/deploy/headless-session-handler.d.ts.map +0 -1
  185. package/dist/server/services/deploy/headless-session-handler.js +0 -266
  186. package/dist/server/services/deploy/headless-session-handler.js.map +0 -1
  187. package/dist/server/services/websocket/deploy-handlers.d.ts +0 -14
  188. package/dist/server/services/websocket/deploy-handlers.d.ts.map +0 -1
  189. package/dist/server/services/websocket/deploy-handlers.js +0 -409
  190. package/dist/server/services/websocket/deploy-handlers.js.map +0 -1
  191. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +0 -11
  192. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +0 -1
  193. package/dist/server/services/websocket/handlers/deploy-handlers.js +0 -176
  194. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +0 -1
  195. package/server/cli/headless/RESEARCH.md +0 -627
  196. package/server/services/deploy/ai-broker.ts +0 -512
  197. package/server/services/deploy/board-execution-handler.ts +0 -847
  198. package/server/services/deploy/credentials.ts +0 -200
  199. package/server/services/deploy/deploy-ai-service.ts +0 -401
  200. package/server/services/deploy/headless-session-handler.ts +0 -414
  201. package/server/services/websocket/deploy-handlers.ts +0 -544
  202. package/server/services/websocket/handlers/deploy-handlers.ts +0 -228
@@ -15,12 +15,13 @@
15
15
  * - front-matter.ts — YAML front matter field editing utility
16
16
  */
17
17
  import { EventEmitter } from 'node:events';
18
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
- import { join } from 'node:path';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
20
+ import { isAbsolute, join, relative, resolve } from 'node:path';
20
21
  import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
21
22
  import { ConfigInstaller } from './config-installer.js';
22
23
  import { resolveReadyToWork } from './dependency-resolver.js';
23
- import { checkAllAcceptanceCriteria, replaceFrontMatterField, setFrontMatterField } from './front-matter.js';
24
+ import { checkAllAcceptanceCriteria, replaceFrontMatterField, setFrontMatterFieldAsync } from './front-matter.js';
24
25
  import { buildIssuePrompt } from './issue-prompt-builder.js';
25
26
  import { runIssueWithRetry } from './issue-retry.js';
26
27
  import { listExistingDocs, publishOutputs, resolveOutputPath } from './output-manager.js';
@@ -70,6 +71,15 @@ export class PlanExecutor extends EventEmitter {
70
71
  this.extraEnv = options?.extraEnv;
71
72
  this.configInstaller = new ConfigInstaller(workingDir);
72
73
  }
74
+ validateIssuePath(issuePath, baseDir) {
75
+ const resolvedBase = resolve(baseDir);
76
+ const resolvedFull = resolve(resolvedBase, issuePath);
77
+ const rel = relative(resolvedBase, resolvedFull);
78
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
79
+ throw new Error(`Invalid issue path: path traversal detected in "${issuePath}"`);
80
+ }
81
+ return resolvedFull;
82
+ }
73
83
  getStatus() { return this.status; }
74
84
  getMetrics() { return { ...this.metrics }; }
75
85
  async startEpic(epicPath) {
@@ -103,32 +113,38 @@ export class PlanExecutor extends EventEmitter {
103
113
  this.emit('statusChanged', this.status);
104
114
  this.pmDir = resolvePmDir(this.workingDir);
105
115
  this.boardDir = this.resolveBoardDir();
106
- this.recoverStaleIssues();
116
+ await this.recoverStaleIssues();
107
117
  const stallResult = await this.runWaveLoop();
108
118
  this.metrics.totalDuration = Date.now() - startTime;
109
- if (stallResult === 'stalled') {
119
+ if (stallResult === 'stalled' || stallResult === 'dead') {
110
120
  this.status = 'error';
111
- this.emit('error', `Execution stalled: ${MAX_CONSECUTIVE_EMPTY_WAVES} consecutive waves completed zero issues. Issues may be stuck in review or failing repeatedly.`);
121
+ if (stallResult === 'stalled') {
122
+ this.emit('error', `Execution stalled: ${MAX_CONSECUTIVE_EMPTY_WAVES} consecutive waves completed zero issues. Issues may be stuck in review or failing repeatedly.`);
123
+ }
112
124
  }
113
125
  else if (this.shouldPause) {
114
126
  this.status = 'paused';
115
127
  }
116
128
  else if (this.shouldStop) {
117
129
  this.status = 'idle';
130
+ // Emit complete so clients can transition out of 'stopping' — metrics are broadcast by the handler.
131
+ this.emit('complete', 'Stopped by user');
118
132
  }
119
133
  else {
120
134
  this.status = 'complete';
121
135
  }
122
136
  this.emit('statusChanged', this.status);
123
137
  }
124
- /** Run waves until done, paused, stopped, or stalled. Returns 'stalled' if zero-completion cap hit. */
138
+ /** Run waves until done, paused, stopped, or stalled. */
125
139
  async runWaveLoop() {
126
140
  let consecutiveZeroCompletions = 0;
127
- const maxParallel = this.getBoardMaxParallelAgents();
141
+ const maxParallel = await this.getBoardMaxParallelAgents();
128
142
  while (!this.shouldStop && !this.shouldPause) {
129
- const readyIssues = this.pickReadyIssues();
130
- if (readyIssues.length === 0)
131
- break;
143
+ const readyIssues = await this.pickReadyIssues();
144
+ if (readyIssues.length === 0) {
145
+ // pickReadyIssues emits 'error' for dead state, 'complete' otherwise — check if dead
146
+ return await this.hasDeadIssues() ? 'dead' : 'done';
147
+ }
132
148
  const completedCount = await this.executeWave(readyIssues.slice(0, maxParallel));
133
149
  if (completedCount > 0) {
134
150
  consecutiveZeroCompletions = 0;
@@ -140,6 +156,19 @@ export class PlanExecutor extends EventEmitter {
140
156
  }
141
157
  return 'done';
142
158
  }
159
+ async hasDeadIssues() {
160
+ const pmDir = this.pmDir;
161
+ if (!pmDir)
162
+ return false;
163
+ const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
164
+ const issues = effectiveBoardId
165
+ ? await this.loadBoardIssues(pmDir, effectiveBoardId)
166
+ : this.loadProjectIssues();
167
+ if (!issues)
168
+ return false;
169
+ const terminalStatuses = new Set(['done', 'cancelled']);
170
+ return issues.some(i => i.type !== 'epic' && !terminalStatuses.has(i.status) && i.status !== 'todo');
171
+ }
143
172
  pause() { this.shouldPause = true; }
144
173
  stop() {
145
174
  this.shouldStop = true;
@@ -166,10 +195,10 @@ export class PlanExecutor extends EventEmitter {
166
195
  this.emit('waveStarted', { issueIds: waveIds });
167
196
  // Create abort controller for this wave — stop() will abort it
168
197
  this.waveAbortController = new AbortController();
169
- this.ensureOutputDirs();
198
+ await this.ensureOutputDirs();
170
199
  this.configInstaller.installPermissions();
171
200
  for (const issue of issues) {
172
- this.updateIssueFrontMatter(issue.path, 'in_progress');
201
+ await this.updateIssueFrontMatter(issue.path, 'in_progress');
173
202
  }
174
203
  const existingDocs = listExistingDocs(this.workingDir, this.boardDir);
175
204
  const pmDir = this.pmDir;
@@ -196,13 +225,13 @@ export class PlanExecutor extends EventEmitter {
196
225
  issueIds: waveIds,
197
226
  error: error instanceof Error ? error.message : String(error),
198
227
  });
199
- this.revertIncompleteIssues(issues);
228
+ await this.revertIncompleteIssues(issues);
200
229
  }
201
230
  finally {
202
231
  this.configInstaller.uninstallPermissions();
203
232
  }
204
233
  this.waveAbortController = null;
205
- this.finalizeWave(issues, waveStart, waveLabel);
234
+ await this.finalizeWave(issues, waveStart, waveLabel);
206
235
  this.metrics.currentWaveIds = [];
207
236
  return completedCount;
208
237
  }
@@ -240,7 +269,7 @@ export class PlanExecutor extends EventEmitter {
240
269
  * Post-wave operations wrapped individually so a failure in one
241
270
  * doesn't prevent the others or kill the while loop in start().
242
271
  */
243
- finalizeWave(issues, waveStart, waveLabel) {
272
+ async finalizeWave(issues, waveStart, waveLabel) {
244
273
  try {
245
274
  reconcileState(this.workingDir, this.boardId ?? undefined);
246
275
  this.emit('stateUpdated');
@@ -263,7 +292,7 @@ export class PlanExecutor extends EventEmitter {
263
292
  });
264
293
  }
265
294
  try {
266
- this.appendProgressEntry(issues, waveStart);
295
+ await this.appendProgressEntry(issues, waveStart);
267
296
  }
268
297
  catch (err) {
269
298
  this.emit('output', {
@@ -284,15 +313,15 @@ export class PlanExecutor extends EventEmitter {
284
313
  return 0;
285
314
  let completed = 0;
286
315
  for (const issue of issues) {
287
- const fullPath = join(pmDir, issue.path);
316
+ const fullPath = this.validateIssuePath(issue.path, pmDir);
288
317
  try {
289
- const content = readFileSync(fullPath, 'utf-8');
318
+ const content = await readFile(fullPath, 'utf-8');
290
319
  const statusMatch = content.match(/^status:\s*(\S+)/m);
291
320
  const currentStatus = statusMatch?.[1] ?? 'unknown';
292
321
  if (currentStatus === 'in_review' || currentStatus === 'done') {
293
322
  if (issue.reviewGate === 'none') {
294
323
  // Skip review gate — mark done directly
295
- this.updateIssueFrontMatter(issue.path, 'done');
324
+ await this.updateIssueFrontMatter(issue.path, 'done');
296
325
  this.metrics.issuesCompleted++;
297
326
  this.emit('issueCompleted', issue);
298
327
  completed++;
@@ -302,7 +331,7 @@ export class PlanExecutor extends EventEmitter {
302
331
  }
303
332
  }
304
333
  else {
305
- this.updateIssueFrontMatter(issue.path, issue.status);
334
+ await this.updateIssueFrontMatter(issue.path, issue.status);
306
335
  this.emit('issueError', {
307
336
  issueId: issue.id,
308
337
  error: 'Issue did not complete during wave execution',
@@ -320,34 +349,40 @@ export class PlanExecutor extends EventEmitter {
320
349
  const reviewDir = this.boardDir ?? pmDir;
321
350
  const attempts = getReviewAttemptCount(reviewDir, issue);
322
351
  if (attempts >= MAX_REVIEW_ATTEMPTS) {
323
- this.updateIssueFrontMatter(issue.path, 'in_review');
352
+ await this.updateIssueFrontMatter(issue.path, 'cancelled');
353
+ await this.appendCancellationNote(issue, pmDir, `Cancelled after ${MAX_REVIEW_ATTEMPTS} failed reviews — issue may need restructuring`);
324
354
  this.emit('reviewProgress', { issueId: issue.id, status: 'max_attempts' });
325
- this.emit('output', { issueId: issue.id, text: 'Review: max attempts reached, keeping in review' });
355
+ this.emit('issueAbandoned', {
356
+ issueId: issue.id,
357
+ reason: `Review failed ${MAX_REVIEW_ATTEMPTS} times — cancelled to unblock dependents`,
358
+ attempts,
359
+ });
360
+ this.emit('output', { issueId: issue.id, text: `Review: max attempts reached, cancelling issue to unblock dependents` });
326
361
  return 0;
327
362
  }
328
- this.updateIssueFrontMatter(issue.path, 'in_review');
363
+ await this.updateIssueFrontMatter(issue.path, 'in_review');
329
364
  this.emit('reviewProgress', { issueId: issue.id, status: 'reviewing' });
330
365
  const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
331
366
  const result = await reviewIssue({
332
- workingDir: this.workingDir,
367
+ workingDir: this.executionDir || this.workingDir,
333
368
  issue,
334
369
  pmDir,
335
370
  outputPath,
336
371
  onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
337
372
  logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
338
- reviewCriteria: this.getBoardReviewCriteria(),
373
+ reviewCriteria: await this.getBoardReviewCriteria(),
339
374
  boardDir: this.boardDir,
340
375
  extraEnv: this.extraEnv,
341
376
  });
342
377
  persistReviewResult(reviewDir, issue, result);
343
378
  if (result.passed) {
344
- this.updateIssueFrontMatter(issue.path, 'done');
379
+ await this.updateIssueFrontMatter(issue.path, 'done');
345
380
  this.metrics.issuesCompleted++;
346
381
  this.emit('reviewProgress', { issueId: issue.id, status: 'passed' });
347
382
  this.emit('issueCompleted', issue);
348
383
  return 1;
349
384
  }
350
- this.updateIssueFrontMatter(issue.path, 'todo');
385
+ await this.updateIssueFrontMatter(issue.path, 'todo');
351
386
  appendReviewFeedback(pmDir, issue, result);
352
387
  this.emit('reviewProgress', { issueId: issue.id, status: 'failed' });
353
388
  this.emit('issueError', {
@@ -363,13 +398,13 @@ export class PlanExecutor extends EventEmitter {
363
398
  * these issues block the dependency graph and cause the executor to
364
399
  * find zero ready issues, making "Implement" appear to do nothing.
365
400
  */
366
- recoverStaleIssues() {
401
+ async recoverStaleIssues() {
367
402
  const pmDir = this.pmDir;
368
403
  if (!pmDir)
369
404
  return;
370
405
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
371
406
  const issues = effectiveBoardId
372
- ? this.loadBoardIssues(pmDir, effectiveBoardId)
407
+ ? await this.loadBoardIssues(pmDir, effectiveBoardId)
373
408
  : this.loadProjectIssues();
374
409
  if (!issues)
375
410
  return;
@@ -379,7 +414,7 @@ export class PlanExecutor extends EventEmitter {
379
414
  if (issue.type === 'epic')
380
415
  continue;
381
416
  if (staleStatuses.has(issue.status)) {
382
- this.updateIssueFrontMatter(issue.path, 'todo');
417
+ await this.updateIssueFrontMatter(issue.path, 'todo');
383
418
  recovered.push(`${issue.id} (${issue.status} → todo)`);
384
419
  }
385
420
  }
@@ -393,28 +428,28 @@ export class PlanExecutor extends EventEmitter {
393
428
  }
394
429
  // ── Helpers ──────────────────────────────────────────────────
395
430
  /** Read the board's maxParallelAgents setting, falling back to default. */
396
- getBoardMaxParallelAgents() {
431
+ async getBoardMaxParallelAgents() {
397
432
  const pmDir = this.pmDir;
398
433
  if (!pmDir)
399
434
  return DEFAULT_MAX_PARALLEL_AGENTS;
400
435
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
401
436
  if (!effectiveBoardId)
402
437
  return DEFAULT_MAX_PARALLEL_AGENTS;
403
- // Read only board.md — avoids parsing STATE.md and all backlog issues just for one setting
404
438
  const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
405
439
  if (!existsSync(boardMdPath))
406
440
  return DEFAULT_MAX_PARALLEL_AGENTS;
407
441
  try {
408
- const content = readFileSync(boardMdPath, 'utf-8');
442
+ const content = await readFile(boardMdPath, 'utf-8');
409
443
  const match = content.match(/^max_parallel_agents:\s*(\d+)/m);
410
444
  return match ? Math.max(1, Math.min(Number(match[1]), 10)) : DEFAULT_MAX_PARALLEL_AGENTS;
411
445
  }
412
- catch {
446
+ catch (err) {
447
+ 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 });
413
448
  return DEFAULT_MAX_PARALLEL_AGENTS;
414
449
  }
415
450
  }
416
451
  /** Read the board's custom review criteria, if set. */
417
- getBoardReviewCriteria() {
452
+ async getBoardReviewCriteria() {
418
453
  const pmDir = this.pmDir;
419
454
  if (!pmDir)
420
455
  return undefined;
@@ -425,18 +460,19 @@ export class PlanExecutor extends EventEmitter {
425
460
  if (!existsSync(boardMdPath))
426
461
  return undefined;
427
462
  try {
428
- const content = readFileSync(boardMdPath, 'utf-8');
463
+ const content = await readFile(boardMdPath, 'utf-8');
429
464
  const match = content.match(/^review_criteria:\s*"(.+)"/m);
430
465
  if (!match)
431
466
  return undefined;
432
467
  const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
433
468
  return raw || undefined;
434
469
  }
435
- catch {
470
+ catch (err) {
471
+ this.emit('output', { issueId: 'system', text: `Warning: failed to read board review criteria: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
436
472
  return undefined;
437
473
  }
438
474
  }
439
- pickReadyIssues() {
475
+ async pickReadyIssues() {
440
476
  const pmDir = this.pmDir;
441
477
  if (!pmDir) {
442
478
  this.emit('error', 'No PM directory found');
@@ -444,21 +480,27 @@ export class PlanExecutor extends EventEmitter {
444
480
  }
445
481
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
446
482
  const issues = effectiveBoardId
447
- ? this.loadBoardIssues(pmDir, effectiveBoardId)
483
+ ? await this.loadBoardIssues(pmDir, effectiveBoardId)
448
484
  : this.loadProjectIssues();
449
485
  if (!issues)
450
486
  return [];
451
487
  const readyIssues = resolveReadyToWork(issues, this.epicScope ?? undefined);
452
488
  if (readyIssues.length === 0) {
453
- this.emit('complete', this.buildCompletionReason(issues));
454
- if (effectiveBoardId) {
455
- this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
489
+ const deadState = this.detectDeadState(issues);
490
+ if (deadState) {
491
+ this.emit('error', deadState);
492
+ }
493
+ else {
494
+ this.emit('complete', this.buildCompletionReason(issues));
495
+ if (effectiveBoardId) {
496
+ await this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
497
+ }
456
498
  }
457
499
  }
458
500
  return readyIssues;
459
501
  }
460
502
  /** Load issues from a specific board, auto-activating draft boards. Returns null on error. */
461
- loadBoardIssues(pmDir, boardId) {
503
+ async loadBoardIssues(pmDir, boardId) {
462
504
  const boardState = parseBoardDirectory(pmDir, boardId);
463
505
  if (!boardState) {
464
506
  this.emit('error', `Board not found: ${boardId}`);
@@ -469,7 +511,7 @@ export class PlanExecutor extends EventEmitter {
469
511
  return null;
470
512
  }
471
513
  if (boardState.board.status === 'draft') {
472
- this.activateBoard(pmDir, boardId);
514
+ await this.activateBoard(pmDir, boardId);
473
515
  }
474
516
  else if (boardState.board.status !== 'active') {
475
517
  this.emit('error', `Board ${boardId} is not active (status: ${boardState.board.status})`);
@@ -491,18 +533,20 @@ export class PlanExecutor extends EventEmitter {
491
533
  return fullState.issues;
492
534
  }
493
535
  /** Activate a draft board by updating its status in board.md. */
494
- activateBoard(pmDir, boardId) {
536
+ async activateBoard(pmDir, boardId) {
495
537
  const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
496
538
  if (!existsSync(boardMdPath))
497
539
  return;
498
540
  try {
499
- const content = readFileSync(boardMdPath, 'utf-8');
500
- writeFileSync(boardMdPath, replaceFrontMatterField(content, 'status', 'active'), 'utf-8');
541
+ const content = await readFile(boardMdPath, 'utf-8');
542
+ await writeFile(boardMdPath, replaceFrontMatterField(content, 'status', 'active'), 'utf-8');
543
+ }
544
+ catch (err) {
545
+ this.emit('output', { issueId: 'system', text: `Warning: failed to activate board ${boardId}: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
501
546
  }
502
- catch { /* non-fatal — pickReadyIssues will re-check */ }
503
547
  }
504
548
  /** Check if all issues in a board are done and mark board as completed. */
505
- tryCompleteBoardIfDone(pmDir, boardId, issues) {
549
+ async tryCompleteBoardIfDone(pmDir, boardId, issues) {
506
550
  const allDone = issues.length > 0 && issues.every(i => i.status === 'done' || i.status === 'cancelled');
507
551
  if (!allDone)
508
552
  return;
@@ -510,12 +554,14 @@ export class PlanExecutor extends EventEmitter {
510
554
  if (!existsSync(boardMdPath))
511
555
  return;
512
556
  try {
513
- let content = readFileSync(boardMdPath, 'utf-8');
557
+ let content = await readFile(boardMdPath, 'utf-8');
514
558
  content = replaceFrontMatterField(content, 'status', 'completed');
515
559
  content = replaceFrontMatterField(content, 'completed_at', `"${new Date().toISOString()}"`);
516
- writeFileSync(boardMdPath, content, 'utf-8');
560
+ await writeFile(boardMdPath, content, 'utf-8');
561
+ }
562
+ catch (err) {
563
+ 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 });
517
564
  }
518
- catch { /* non-fatal */ }
519
565
  }
520
566
  resolveActiveBoardId() {
521
567
  const pmDir = this.pmDir;
@@ -542,58 +588,97 @@ export class PlanExecutor extends EventEmitter {
542
588
  return `${done}/${nonEpic.length} issues done, ${blocked} blocked by incomplete dependencies`;
543
589
  return this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
544
590
  }
545
- revertIncompleteIssues(issues) {
591
+ /** Detect issues stuck in non-terminal states with no path to completion. */
592
+ detectDeadState(issues) {
593
+ const nonEpic = issues.filter(i => i.type !== 'epic');
594
+ const terminalStatuses = new Set(['done', 'cancelled']);
595
+ const stuck = nonEpic.filter(i => !terminalStatuses.has(i.status) && i.status !== 'todo');
596
+ if (stuck.length === 0)
597
+ return null;
598
+ const stuckIds = stuck.map(i => `${i.id} (${i.status})`).join(', ');
599
+ const issueByPath = new Map(issues.map(i => [i.path, i]));
600
+ const blockedByStuck = nonEpic.filter(i => {
601
+ if (i.status !== 'todo')
602
+ return false;
603
+ return i.blockedBy.some(bp => {
604
+ const blocker = issueByPath.get(bp);
605
+ return blocker && !terminalStatuses.has(blocker.status);
606
+ });
607
+ });
608
+ const blockedIds = blockedByStuck.map(i => i.id).join(', ');
609
+ return `Board stuck: ${stuckIds} cannot progress${blockedIds ? `. Blocking: ${blockedIds}` : ''}`;
610
+ }
611
+ async revertIncompleteIssues(issues) {
546
612
  const pmDir = this.pmDir;
547
613
  if (!pmDir)
548
614
  return;
549
615
  for (const issue of issues) {
550
- const fullPath = join(pmDir, issue.path);
616
+ const fullPath = this.validateIssuePath(issue.path, pmDir);
551
617
  try {
552
- const content = readFileSync(fullPath, 'utf-8');
618
+ const content = await readFile(fullPath, 'utf-8');
553
619
  if (content.match(/^status:\s*in_progress$/m)) {
554
- this.updateIssueFrontMatter(issue.path, issue.status);
620
+ await this.updateIssueFrontMatter(issue.path, issue.status);
555
621
  }
556
622
  }
557
- catch { /* file may be gone */ }
623
+ catch (err) {
624
+ this.emit('output', { issueId: issue.id, text: `Warning: failed to revert issue status: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
625
+ }
626
+ }
627
+ }
628
+ async appendCancellationNote(issue, pmDir, reason) {
629
+ const fullPath = this.validateIssuePath(issue.path, pmDir);
630
+ try {
631
+ let content = await readFile(fullPath, 'utf-8');
632
+ const entry = `- Cancelled (${new Date().toISOString().split('T')[0]}): ${reason}`;
633
+ if (content.includes('## Activity')) {
634
+ content = content.replace(/## Activity/, `## Activity\n${entry}`);
635
+ }
636
+ else {
637
+ content += `\n\n## Activity\n${entry}`;
638
+ }
639
+ await writeFile(fullPath, content, 'utf-8');
640
+ }
641
+ catch (err) {
642
+ this.emit('output', { issueId: issue.id, text: `Warning: failed to append cancellation note: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
558
643
  }
559
644
  }
560
- updateIssueFrontMatter(issuePath, newStatus) {
645
+ async updateIssueFrontMatter(issuePath, newStatus) {
561
646
  const pmDir = this.pmDir;
562
647
  if (!pmDir)
563
648
  return;
564
649
  try {
565
- const fullPath = join(pmDir, issuePath);
566
- setFrontMatterField(fullPath, 'status', newStatus);
567
- // Check off all acceptance criteria when marking done
650
+ const fullPath = this.validateIssuePath(issuePath, pmDir);
651
+ await setFrontMatterFieldAsync(fullPath, 'status', newStatus);
568
652
  if (newStatus === 'done') {
569
- const content = readFileSync(fullPath, 'utf-8');
653
+ const content = await readFile(fullPath, 'utf-8');
570
654
  const updated = checkAllAcceptanceCriteria(content);
571
655
  if (updated !== content)
572
- writeFileSync(fullPath, updated, 'utf-8');
656
+ await writeFile(fullPath, updated, 'utf-8');
573
657
  }
574
658
  }
575
- catch { /* file may have been moved */ }
659
+ catch (err) {
660
+ 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 });
661
+ }
576
662
  }
577
- ensureOutputDirs() {
663
+ async ensureOutputDirs() {
578
664
  if (this.boardDir) {
579
665
  const boardOutDir = join(this.boardDir, 'out');
580
666
  if (!existsSync(boardOutDir))
581
- mkdirSync(boardOutDir, { recursive: true });
667
+ await mkdir(boardOutDir, { recursive: true });
582
668
  }
583
669
  else {
584
670
  const pmDir = this.pmDir;
585
671
  if (pmDir) {
586
672
  const outDir = join(pmDir, 'out');
587
673
  if (!existsSync(outDir))
588
- mkdirSync(outDir, { recursive: true });
674
+ await mkdir(outDir, { recursive: true });
589
675
  }
590
676
  }
591
677
  }
592
- appendProgressEntry(issues, waveStart) {
678
+ async appendProgressEntry(issues, waveStart) {
593
679
  const pmDir = this.pmDir;
594
680
  if (!pmDir)
595
681
  return;
596
- // Board-scoped progress log
597
682
  const progressPath = this.boardDir
598
683
  ? join(this.boardDir, 'progress.md')
599
684
  : join(pmDir, 'progress.md');
@@ -603,7 +688,7 @@ export class PlanExecutor extends EventEmitter {
603
688
  const failed = [];
604
689
  for (const issue of issues) {
605
690
  try {
606
- const content = readFileSync(join(pmDir, issue.path), 'utf-8');
691
+ const content = await readFile(this.validateIssuePath(issue.path, pmDir), 'utf-8');
607
692
  const statusMatch = content.match(/^status:\s*(\S+)/m);
608
693
  if (statusMatch?.[1] === 'done') {
609
694
  completed.push(issue.id);
@@ -627,19 +712,20 @@ export class PlanExecutor extends EventEmitter {
627
712
  lines.push(`- **Failed**: ${failed.join(', ')}`);
628
713
  }
629
714
  lines.push('');
630
- this.writeProgressLines(progressPath, lines);
715
+ await this.writeProgressLines(progressPath, lines);
631
716
  }
632
- writeProgressLines(filePath, lines) {
717
+ async writeProgressLines(filePath, lines) {
633
718
  try {
634
719
  if (existsSync(filePath)) {
635
- const existing = readFileSync(filePath, 'utf-8');
636
- writeFileSync(filePath, `${existing.trimEnd()}\n${lines.join('\n')}`, 'utf-8');
720
+ await appendFile(filePath, `\n${lines.join('\n')}`, 'utf-8');
637
721
  }
638
722
  else {
639
- writeFileSync(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
723
+ await writeFile(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
640
724
  }
641
725
  }
642
- catch { /* non-fatal */ }
726
+ catch (err) {
727
+ this.emit('output', { issueId: 'system', text: `Warning: failed to write progress log: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
728
+ }
643
729
  }
644
730
  /** Resolve the active board's directory path for outputs, reviews, and progress. */
645
731
  resolveBoardDir() {