mstro-app 0.4.39 → 0.4.43

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 (197) hide show
  1. package/bin/commands/login.js +17 -7
  2. package/bin/commands/logout.js +14 -6
  3. package/bin/commands/status.js +9 -3
  4. package/bin/commands/whoami.js +10 -4
  5. package/bin/mstro.js +11 -1
  6. package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
  7. package/dist/server/cli/headless/claude-invoker-stream.js +1 -0
  8. package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
  9. package/dist/server/cli/headless/index.d.ts +1 -0
  10. package/dist/server/cli/headless/index.d.ts.map +1 -1
  11. package/dist/server/cli/headless/index.js +2 -0
  12. package/dist/server/cli/headless/index.js.map +1 -1
  13. package/dist/server/cli/headless/resilient-runner.d.ts +47 -0
  14. package/dist/server/cli/headless/resilient-runner.d.ts.map +1 -0
  15. package/dist/server/cli/headless/resilient-runner.js +234 -0
  16. package/dist/server/cli/headless/resilient-runner.js.map +1 -0
  17. package/dist/server/cli/headless/retry-strategies.d.ts +44 -0
  18. package/dist/server/cli/headless/retry-strategies.d.ts.map +1 -0
  19. package/dist/server/cli/headless/retry-strategies.js +262 -0
  20. package/dist/server/cli/headless/retry-strategies.js.map +1 -0
  21. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  22. package/dist/server/cli/headless/stall-assessor.js +5 -0
  23. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  24. package/dist/server/cli/headless/tool-watchdog.d.ts +2 -0
  25. package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
  26. package/dist/server/cli/headless/tool-watchdog.js +31 -4
  27. package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
  28. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  29. package/dist/server/cli/improvisation-retry.js +1 -30
  30. package/dist/server/cli/improvisation-retry.js.map +1 -1
  31. package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
  32. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  33. package/dist/server/cli/improvisation-session-manager.js +16 -3
  34. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  35. package/dist/server/cli/prompt-builders.d.ts.map +1 -1
  36. package/dist/server/cli/prompt-builders.js +31 -13
  37. package/dist/server/cli/prompt-builders.js.map +1 -1
  38. package/dist/server/index.js +1 -9
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/mcp/bouncer-cli.js +5 -4
  41. package/dist/server/mcp/bouncer-cli.js.map +1 -1
  42. package/dist/server/mcp/bouncer-haiku.js +1 -1
  43. package/dist/server/mcp/bouncer-haiku.js.map +1 -1
  44. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  45. package/dist/server/mcp/bouncer-integration.js +14 -8
  46. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  47. package/dist/server/mcp/security-patterns.js +1 -1
  48. package/dist/server/mcp/security-patterns.js.map +1 -1
  49. package/dist/server/services/plan/composer.d.ts.map +1 -1
  50. package/dist/server/services/plan/composer.js +19 -9
  51. package/dist/server/services/plan/composer.js.map +1 -1
  52. package/dist/server/services/plan/executor.d.ts +6 -1
  53. package/dist/server/services/plan/executor.d.ts.map +1 -1
  54. package/dist/server/services/plan/executor.js +158 -76
  55. package/dist/server/services/plan/executor.js.map +1 -1
  56. package/dist/server/services/plan/front-matter.d.ts +1 -0
  57. package/dist/server/services/plan/front-matter.d.ts.map +1 -1
  58. package/dist/server/services/plan/front-matter.js +6 -0
  59. package/dist/server/services/plan/front-matter.js.map +1 -1
  60. package/dist/server/services/plan/issue-classification.d.ts +11 -0
  61. package/dist/server/services/plan/issue-classification.d.ts.map +1 -0
  62. package/dist/server/services/plan/issue-classification.js +20 -0
  63. package/dist/server/services/plan/issue-classification.js.map +1 -0
  64. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  65. package/dist/server/services/plan/issue-prompt-builder.js +7 -4
  66. package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
  67. package/dist/server/services/plan/issue-retry.d.ts +0 -5
  68. package/dist/server/services/plan/issue-retry.d.ts.map +1 -1
  69. package/dist/server/services/plan/issue-retry.js +12 -241
  70. package/dist/server/services/plan/issue-retry.js.map +1 -1
  71. package/dist/server/services/plan/parser-core.d.ts.map +1 -1
  72. package/dist/server/services/plan/parser-core.js +1 -0
  73. package/dist/server/services/plan/parser-core.js.map +1 -1
  74. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  75. package/dist/server/services/plan/review-gate.js +9 -6
  76. package/dist/server/services/plan/review-gate.js.map +1 -1
  77. package/dist/server/services/plan/types.d.ts +1 -0
  78. package/dist/server/services/plan/types.d.ts.map +1 -1
  79. package/dist/server/services/platform-credentials.d.ts.map +1 -1
  80. package/dist/server/services/platform-credentials.js +11 -4
  81. package/dist/server/services/platform-credentials.js.map +1 -1
  82. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
  83. package/dist/server/services/terminal/pty-manager.js +7 -1
  84. package/dist/server/services/terminal/pty-manager.js.map +1 -1
  85. package/dist/server/services/websocket/handler-context.d.ts +2 -0
  86. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  87. package/dist/server/services/websocket/handler.d.ts +2 -0
  88. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  89. package/dist/server/services/websocket/handler.js +18 -7
  90. package/dist/server/services/websocket/handler.js.map +1 -1
  91. package/dist/server/services/websocket/plan-execution-handlers.js +6 -6
  92. package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
  93. package/dist/server/services/websocket/quality-fix-agent.d.ts.map +1 -1
  94. package/dist/server/services/websocket/quality-fix-agent.js +90 -42
  95. package/dist/server/services/websocket/quality-fix-agent.js.map +1 -1
  96. package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
  97. package/dist/server/services/websocket/quality-handlers.js +48 -7
  98. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  99. package/dist/server/services/websocket/quality-persistence.d.ts +22 -0
  100. package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
  101. package/dist/server/services/websocket/quality-persistence.js +48 -1
  102. package/dist/server/services/websocket/quality-persistence.js.map +1 -1
  103. package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
  104. package/dist/server/services/websocket/quality-review-agent.js +74 -32
  105. package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
  106. package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
  107. package/dist/server/services/websocket/quality-tools.js +18 -18
  108. package/dist/server/services/websocket/quality-tools.js.map +1 -1
  109. package/dist/server/services/websocket/skill-handlers.d.ts +3 -1
  110. package/dist/server/services/websocket/skill-handlers.d.ts.map +1 -1
  111. package/dist/server/services/websocket/skill-handlers.js +52 -41
  112. package/dist/server/services/websocket/skill-handlers.js.map +1 -1
  113. package/dist/server/services/websocket/skill-watcher.d.ts +17 -0
  114. package/dist/server/services/websocket/skill-watcher.d.ts.map +1 -0
  115. package/dist/server/services/websocket/skill-watcher.js +85 -0
  116. package/dist/server/services/websocket/skill-watcher.js.map +1 -0
  117. package/dist/server/services/websocket/types.d.ts +2 -268
  118. package/dist/server/services/websocket/types.d.ts.map +1 -1
  119. package/dist/server/services/websocket/types.js +0 -4
  120. package/dist/server/services/websocket/types.js.map +1 -1
  121. package/package.json +1 -1
  122. package/server/cli/headless/claude-invoker-stream.ts +1 -0
  123. package/server/cli/headless/index.ts +2 -0
  124. package/server/cli/headless/resilient-runner.ts +354 -0
  125. package/server/cli/headless/retry-strategies.ts +330 -0
  126. package/server/cli/headless/stall-assessor.ts +5 -0
  127. package/server/cli/headless/tool-watchdog.ts +40 -4
  128. package/server/cli/improvisation-retry.ts +1 -32
  129. package/server/cli/improvisation-session-manager.ts +17 -3
  130. package/server/cli/prompt-builders.ts +33 -12
  131. package/server/index.ts +1 -9
  132. package/server/mcp/bouncer-cli.ts +5 -4
  133. package/server/mcp/bouncer-haiku.ts +1 -1
  134. package/server/mcp/bouncer-integration.ts +15 -8
  135. package/server/mcp/security-patterns.ts +1 -1
  136. package/server/services/plan/agents/code-review.md +109 -0
  137. package/server/services/plan/agents/commit-message.md +26 -0
  138. package/server/services/plan/agents/fix-quality.md +24 -0
  139. package/server/services/plan/agents/pr-description.md +28 -0
  140. package/server/services/plan/composer.ts +20 -9
  141. package/server/services/plan/executor.ts +160 -76
  142. package/server/services/plan/front-matter.ts +7 -0
  143. package/server/services/plan/issue-classification.ts +21 -0
  144. package/server/services/plan/issue-prompt-builder.ts +8 -4
  145. package/server/services/plan/issue-retry.ts +15 -330
  146. package/server/services/plan/parser-core.ts +1 -0
  147. package/server/services/plan/review-gate.ts +9 -6
  148. package/server/services/plan/types.ts +3 -0
  149. package/server/services/platform-credentials.ts +10 -4
  150. package/server/services/terminal/pty-manager.ts +7 -1
  151. package/server/services/websocket/handler-context.ts +2 -0
  152. package/server/services/websocket/handler.ts +18 -8
  153. package/server/services/websocket/plan-execution-handlers.ts +7 -7
  154. package/server/services/websocket/quality-fix-agent.ts +86 -44
  155. package/server/services/websocket/quality-handlers.ts +48 -7
  156. package/server/services/websocket/quality-persistence.ts +75 -1
  157. package/server/services/websocket/quality-review-agent.ts +70 -31
  158. package/server/services/websocket/quality-tools.ts +16 -14
  159. package/server/services/websocket/skill-handlers.ts +50 -40
  160. package/server/services/websocket/skill-watcher.ts +79 -0
  161. package/server/services/websocket/types.ts +0 -311
  162. package/dist/server/services/deploy/ai-broker.d.ts +0 -63
  163. package/dist/server/services/deploy/ai-broker.d.ts.map +0 -1
  164. package/dist/server/services/deploy/ai-broker.js +0 -360
  165. package/dist/server/services/deploy/ai-broker.js.map +0 -1
  166. package/dist/server/services/deploy/board-execution-handler.d.ts +0 -114
  167. package/dist/server/services/deploy/board-execution-handler.d.ts.map +0 -1
  168. package/dist/server/services/deploy/board-execution-handler.js +0 -621
  169. package/dist/server/services/deploy/board-execution-handler.js.map +0 -1
  170. package/dist/server/services/deploy/credentials.d.ts +0 -35
  171. package/dist/server/services/deploy/credentials.d.ts.map +0 -1
  172. package/dist/server/services/deploy/credentials.js +0 -177
  173. package/dist/server/services/deploy/credentials.js.map +0 -1
  174. package/dist/server/services/deploy/deploy-ai-service.d.ts +0 -107
  175. package/dist/server/services/deploy/deploy-ai-service.d.ts.map +0 -1
  176. package/dist/server/services/deploy/deploy-ai-service.js +0 -294
  177. package/dist/server/services/deploy/deploy-ai-service.js.map +0 -1
  178. package/dist/server/services/deploy/headless-session-handler.d.ts +0 -94
  179. package/dist/server/services/deploy/headless-session-handler.d.ts.map +0 -1
  180. package/dist/server/services/deploy/headless-session-handler.js +0 -266
  181. package/dist/server/services/deploy/headless-session-handler.js.map +0 -1
  182. package/dist/server/services/websocket/deploy-handlers.d.ts +0 -14
  183. package/dist/server/services/websocket/deploy-handlers.d.ts.map +0 -1
  184. package/dist/server/services/websocket/deploy-handlers.js +0 -409
  185. package/dist/server/services/websocket/deploy-handlers.js.map +0 -1
  186. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +0 -11
  187. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +0 -1
  188. package/dist/server/services/websocket/handlers/deploy-handlers.js +0 -176
  189. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +0 -1
  190. package/server/cli/headless/RESEARCH.md +0 -627
  191. package/server/services/deploy/ai-broker.ts +0 -512
  192. package/server/services/deploy/board-execution-handler.ts +0 -847
  193. package/server/services/deploy/credentials.ts +0 -200
  194. package/server/services/deploy/deploy-ai-service.ts +0 -401
  195. package/server/services/deploy/headless-session-handler.ts +0 -414
  196. package/server/services/websocket/deploy-handlers.ts +0 -544
  197. package/server/services/websocket/handlers/deploy-handlers.ts +0 -228
@@ -17,12 +17,13 @@
17
17
  */
18
18
 
19
19
  import { EventEmitter } from 'node:events';
20
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
- import { join } from 'node:path';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
22
+ import { join, resolve } from 'node:path';
22
23
  import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
23
24
  import { ConfigInstaller } from './config-installer.js';
24
25
  import { resolveReadyToWork } from './dependency-resolver.js';
25
- import { checkAllAcceptanceCriteria, replaceFrontMatterField, setFrontMatterField } from './front-matter.js';
26
+ import { checkAllAcceptanceCriteria, replaceFrontMatterField, setFrontMatterFieldAsync } from './front-matter.js';
26
27
  import { buildIssuePrompt } from './issue-prompt-builder.js';
27
28
  import { runIssueWithRetry } from './issue-retry.js';
28
29
  import { listExistingDocs, publishOutputs, resolveOutputPath } from './output-manager.js';
@@ -90,6 +91,14 @@ export class PlanExecutor extends EventEmitter {
90
91
  this.configInstaller = new ConfigInstaller(workingDir);
91
92
  }
92
93
 
94
+ private validateIssuePath(issuePath: string, baseDir: string): string {
95
+ const fullPath = join(baseDir, issuePath);
96
+ if (!resolve(fullPath).startsWith(resolve(baseDir))) {
97
+ throw new Error(`Invalid issue path: path traversal detected in "${issuePath}"`);
98
+ }
99
+ return fullPath;
100
+ }
101
+
93
102
  getStatus(): ExecutionStatus { return this.status; }
94
103
  getMetrics(): ExecutionMetrics { return { ...this.metrics }; }
95
104
 
@@ -129,15 +138,17 @@ export class PlanExecutor extends EventEmitter {
129
138
  this.pmDir = resolvePmDir(this.workingDir);
130
139
  this.boardDir = this.resolveBoardDir();
131
140
 
132
- this.recoverStaleIssues();
141
+ await this.recoverStaleIssues();
133
142
 
134
143
  const stallResult = await this.runWaveLoop();
135
144
 
136
145
  this.metrics.totalDuration = Date.now() - startTime;
137
146
 
138
- if (stallResult === 'stalled') {
147
+ if (stallResult === 'stalled' || stallResult === 'dead') {
139
148
  this.status = 'error';
140
- this.emit('error', `Execution stalled: ${MAX_CONSECUTIVE_EMPTY_WAVES} consecutive waves completed zero issues. Issues may be stuck in review or failing repeatedly.`);
149
+ if (stallResult === 'stalled') {
150
+ this.emit('error', `Execution stalled: ${MAX_CONSECUTIVE_EMPTY_WAVES} consecutive waves completed zero issues. Issues may be stuck in review or failing repeatedly.`);
151
+ }
141
152
  } else if (this.shouldPause) {
142
153
  this.status = 'paused';
143
154
  } else if (this.shouldStop) {
@@ -148,14 +159,17 @@ export class PlanExecutor extends EventEmitter {
148
159
  this.emit('statusChanged', this.status);
149
160
  }
150
161
 
151
- /** Run waves until done, paused, stopped, or stalled. Returns 'stalled' if zero-completion cap hit. */
152
- private async runWaveLoop(): Promise<'done' | 'stalled'> {
162
+ /** Run waves until done, paused, stopped, or stalled. */
163
+ private async runWaveLoop(): Promise<'done' | 'stalled' | 'dead'> {
153
164
  let consecutiveZeroCompletions = 0;
154
- const maxParallel = this.getBoardMaxParallelAgents();
165
+ const maxParallel = await this.getBoardMaxParallelAgents();
155
166
 
156
167
  while (!this.shouldStop && !this.shouldPause) {
157
- const readyIssues = this.pickReadyIssues();
158
- if (readyIssues.length === 0) break;
168
+ const readyIssues = await this.pickReadyIssues();
169
+ if (readyIssues.length === 0) {
170
+ // pickReadyIssues emits 'error' for dead state, 'complete' otherwise — check if dead
171
+ return await this.hasDeadIssues() ? 'dead' : 'done';
172
+ }
159
173
 
160
174
  const completedCount = await this.executeWave(readyIssues.slice(0, maxParallel));
161
175
 
@@ -169,6 +183,18 @@ export class PlanExecutor extends EventEmitter {
169
183
  return 'done';
170
184
  }
171
185
 
186
+ private async hasDeadIssues(): Promise<boolean> {
187
+ const pmDir = this.pmDir;
188
+ if (!pmDir) return false;
189
+ const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
190
+ const issues = effectiveBoardId
191
+ ? await this.loadBoardIssues(pmDir, effectiveBoardId)
192
+ : this.loadProjectIssues();
193
+ if (!issues) return false;
194
+ const terminalStatuses = new Set(['done', 'cancelled']);
195
+ return issues.some(i => i.type !== 'epic' && !terminalStatuses.has(i.status) && i.status !== 'todo');
196
+ }
197
+
172
198
  pause(): void { this.shouldPause = true; }
173
199
  stop(): void {
174
200
  this.shouldStop = true;
@@ -199,11 +225,11 @@ export class PlanExecutor extends EventEmitter {
199
225
  // Create abort controller for this wave — stop() will abort it
200
226
  this.waveAbortController = new AbortController();
201
227
 
202
- this.ensureOutputDirs();
228
+ await this.ensureOutputDirs();
203
229
  this.configInstaller.installPermissions();
204
230
 
205
231
  for (const issue of issues) {
206
- this.updateIssueFrontMatter(issue.path, 'in_progress');
232
+ await this.updateIssueFrontMatter(issue.path, 'in_progress');
207
233
  }
208
234
 
209
235
  const existingDocs = listExistingDocs(this.workingDir, this.boardDir);
@@ -234,13 +260,13 @@ export class PlanExecutor extends EventEmitter {
234
260
  issueIds: waveIds,
235
261
  error: error instanceof Error ? error.message : String(error),
236
262
  });
237
- this.revertIncompleteIssues(issues);
263
+ await this.revertIncompleteIssues(issues);
238
264
  } finally {
239
265
  this.configInstaller.uninstallPermissions();
240
266
  }
241
267
 
242
268
  this.waveAbortController = null;
243
- this.finalizeWave(issues, waveStart, waveLabel);
269
+ await this.finalizeWave(issues, waveStart, waveLabel);
244
270
  this.metrics.currentWaveIds = [];
245
271
  return completedCount;
246
272
  }
@@ -288,7 +314,7 @@ export class PlanExecutor extends EventEmitter {
288
314
  * Post-wave operations wrapped individually so a failure in one
289
315
  * doesn't prevent the others or kill the while loop in start().
290
316
  */
291
- private finalizeWave(issues: Issue[], waveStart: number, waveLabel: string): void {
317
+ private async finalizeWave(issues: Issue[], waveStart: number, waveLabel: string): Promise<void> {
292
318
  try {
293
319
  reconcileState(this.workingDir, this.boardId ?? undefined);
294
320
  this.emit('stateUpdated');
@@ -311,7 +337,7 @@ export class PlanExecutor extends EventEmitter {
311
337
  }
312
338
 
313
339
  try {
314
- this.appendProgressEntry(issues, waveStart);
340
+ await this.appendProgressEntry(issues, waveStart);
315
341
  } catch (err) {
316
342
  this.emit('output', {
317
343
  issueId: waveLabel,
@@ -334,16 +360,16 @@ export class PlanExecutor extends EventEmitter {
334
360
  let completed = 0;
335
361
 
336
362
  for (const issue of issues) {
337
- const fullPath = join(pmDir, issue.path);
363
+ const fullPath = this.validateIssuePath(issue.path, pmDir);
338
364
  try {
339
- const content = readFileSync(fullPath, 'utf-8');
365
+ const content = await readFile(fullPath, 'utf-8');
340
366
  const statusMatch = content.match(/^status:\s*(\S+)/m);
341
367
  const currentStatus = statusMatch?.[1] ?? 'unknown';
342
368
 
343
369
  if (currentStatus === 'in_review' || currentStatus === 'done') {
344
370
  if (issue.reviewGate === 'none') {
345
371
  // Skip review gate — mark done directly
346
- this.updateIssueFrontMatter(issue.path, 'done');
372
+ await this.updateIssueFrontMatter(issue.path, 'done');
347
373
  this.metrics.issuesCompleted++;
348
374
  this.emit('issueCompleted', issue);
349
375
  completed++;
@@ -351,7 +377,7 @@ export class PlanExecutor extends EventEmitter {
351
377
  completed += await this.runReviewGate(issue, pmDir);
352
378
  }
353
379
  } else {
354
- this.updateIssueFrontMatter(issue.path, issue.status);
380
+ await this.updateIssueFrontMatter(issue.path, issue.status);
355
381
  this.emit('issueError', {
356
382
  issueId: issue.id,
357
383
  error: 'Issue did not complete during wave execution',
@@ -370,13 +396,19 @@ export class PlanExecutor extends EventEmitter {
370
396
  const reviewDir = this.boardDir ?? pmDir;
371
397
  const attempts = getReviewAttemptCount(reviewDir, issue);
372
398
  if (attempts >= MAX_REVIEW_ATTEMPTS) {
373
- this.updateIssueFrontMatter(issue.path, 'in_review');
399
+ await this.updateIssueFrontMatter(issue.path, 'cancelled');
400
+ await this.appendCancellationNote(issue, pmDir, `Cancelled after ${MAX_REVIEW_ATTEMPTS} failed reviews — issue may need restructuring`);
374
401
  this.emit('reviewProgress', { issueId: issue.id, status: 'max_attempts' });
375
- this.emit('output', { issueId: issue.id, text: 'Review: max attempts reached, keeping in review' });
402
+ this.emit('issueAbandoned', {
403
+ issueId: issue.id,
404
+ reason: `Review failed ${MAX_REVIEW_ATTEMPTS} times — cancelled to unblock dependents`,
405
+ attempts,
406
+ });
407
+ this.emit('output', { issueId: issue.id, text: `Review: max attempts reached, cancelling issue to unblock dependents` });
376
408
  return 0;
377
409
  }
378
410
 
379
- this.updateIssueFrontMatter(issue.path, 'in_review');
411
+ await this.updateIssueFrontMatter(issue.path, 'in_review');
380
412
  this.emit('reviewProgress', { issueId: issue.id, status: 'reviewing' });
381
413
 
382
414
  const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
@@ -387,21 +419,21 @@ export class PlanExecutor extends EventEmitter {
387
419
  outputPath,
388
420
  onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
389
421
  logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
390
- reviewCriteria: this.getBoardReviewCriteria(),
422
+ reviewCriteria: await this.getBoardReviewCriteria(),
391
423
  boardDir: this.boardDir,
392
424
  extraEnv: this.extraEnv,
393
425
  });
394
426
  persistReviewResult(reviewDir, issue, result);
395
427
 
396
428
  if (result.passed) {
397
- this.updateIssueFrontMatter(issue.path, 'done');
429
+ await this.updateIssueFrontMatter(issue.path, 'done');
398
430
  this.metrics.issuesCompleted++;
399
431
  this.emit('reviewProgress', { issueId: issue.id, status: 'passed' });
400
432
  this.emit('issueCompleted', issue);
401
433
  return 1;
402
434
  }
403
435
 
404
- this.updateIssueFrontMatter(issue.path, 'todo');
436
+ await this.updateIssueFrontMatter(issue.path, 'todo');
405
437
  appendReviewFeedback(pmDir, issue, result);
406
438
  this.emit('reviewProgress', { issueId: issue.id, status: 'failed' });
407
439
  this.emit('issueError', {
@@ -419,13 +451,13 @@ export class PlanExecutor extends EventEmitter {
419
451
  * these issues block the dependency graph and cause the executor to
420
452
  * find zero ready issues, making "Implement" appear to do nothing.
421
453
  */
422
- private recoverStaleIssues(): void {
454
+ private async recoverStaleIssues(): Promise<void> {
423
455
  const pmDir = this.pmDir;
424
456
  if (!pmDir) return;
425
457
 
426
458
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
427
459
  const issues = effectiveBoardId
428
- ? this.loadBoardIssues(pmDir, effectiveBoardId)
460
+ ? await this.loadBoardIssues(pmDir, effectiveBoardId)
429
461
  : this.loadProjectIssues();
430
462
 
431
463
  if (!issues) return;
@@ -436,7 +468,7 @@ export class PlanExecutor extends EventEmitter {
436
468
  for (const issue of issues) {
437
469
  if (issue.type === 'epic') continue;
438
470
  if (staleStatuses.has(issue.status)) {
439
- this.updateIssueFrontMatter(issue.path, 'todo');
471
+ await this.updateIssueFrontMatter(issue.path, 'todo');
440
472
  recovered.push(`${issue.id} (${issue.status} → todo)`);
441
473
  }
442
474
  }
@@ -453,28 +485,28 @@ export class PlanExecutor extends EventEmitter {
453
485
  // ── Helpers ──────────────────────────────────────────────────
454
486
 
455
487
  /** Read the board's maxParallelAgents setting, falling back to default. */
456
- private getBoardMaxParallelAgents(): number {
488
+ private async getBoardMaxParallelAgents(): Promise<number> {
457
489
  const pmDir = this.pmDir;
458
490
  if (!pmDir) return DEFAULT_MAX_PARALLEL_AGENTS;
459
491
 
460
492
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
461
493
  if (!effectiveBoardId) return DEFAULT_MAX_PARALLEL_AGENTS;
462
494
 
463
- // Read only board.md — avoids parsing STATE.md and all backlog issues just for one setting
464
495
  const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
465
496
  if (!existsSync(boardMdPath)) return DEFAULT_MAX_PARALLEL_AGENTS;
466
497
 
467
498
  try {
468
- const content = readFileSync(boardMdPath, 'utf-8');
499
+ const content = await readFile(boardMdPath, 'utf-8');
469
500
  const match = content.match(/^max_parallel_agents:\s*(\d+)/m);
470
501
  return match ? Math.max(1, Math.min(Number(match[1]), 10)) : DEFAULT_MAX_PARALLEL_AGENTS;
471
- } catch {
502
+ } catch (err) {
503
+ 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 });
472
504
  return DEFAULT_MAX_PARALLEL_AGENTS;
473
505
  }
474
506
  }
475
507
 
476
508
  /** Read the board's custom review criteria, if set. */
477
- private getBoardReviewCriteria(): string | undefined {
509
+ private async getBoardReviewCriteria(): Promise<string | undefined> {
478
510
  const pmDir = this.pmDir;
479
511
  if (!pmDir) return undefined;
480
512
 
@@ -485,17 +517,18 @@ export class PlanExecutor extends EventEmitter {
485
517
  if (!existsSync(boardMdPath)) return undefined;
486
518
 
487
519
  try {
488
- const content = readFileSync(boardMdPath, 'utf-8');
520
+ const content = await readFile(boardMdPath, 'utf-8');
489
521
  const match = content.match(/^review_criteria:\s*"(.+)"/m);
490
522
  if (!match) return undefined;
491
523
  const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
492
524
  return raw || undefined;
493
- } catch {
525
+ } catch (err) {
526
+ this.emit('output', { issueId: 'system', text: `Warning: failed to read board review criteria: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
494
527
  return undefined;
495
528
  }
496
529
  }
497
530
 
498
- private pickReadyIssues(): Issue[] {
531
+ private async pickReadyIssues(): Promise<Issue[]> {
499
532
  const pmDir = this.pmDir;
500
533
  if (!pmDir) {
501
534
  this.emit('error', 'No PM directory found');
@@ -504,23 +537,28 @@ export class PlanExecutor extends EventEmitter {
504
537
 
505
538
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
506
539
  const issues = effectiveBoardId
507
- ? this.loadBoardIssues(pmDir, effectiveBoardId)
540
+ ? await this.loadBoardIssues(pmDir, effectiveBoardId)
508
541
  : this.loadProjectIssues();
509
542
 
510
543
  if (!issues) return [];
511
544
 
512
545
  const readyIssues = resolveReadyToWork(issues, this.epicScope ?? undefined);
513
546
  if (readyIssues.length === 0) {
514
- this.emit('complete', this.buildCompletionReason(issues));
515
- if (effectiveBoardId) {
516
- this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
547
+ const deadState = this.detectDeadState(issues);
548
+ if (deadState) {
549
+ this.emit('error', deadState);
550
+ } else {
551
+ this.emit('complete', this.buildCompletionReason(issues));
552
+ if (effectiveBoardId) {
553
+ await this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
554
+ }
517
555
  }
518
556
  }
519
557
  return readyIssues;
520
558
  }
521
559
 
522
560
  /** Load issues from a specific board, auto-activating draft boards. Returns null on error. */
523
- private loadBoardIssues(pmDir: string, boardId: string): Issue[] | null {
561
+ private async loadBoardIssues(pmDir: string, boardId: string): Promise<Issue[] | null> {
524
562
  const boardState = parseBoardDirectory(pmDir, boardId);
525
563
  if (!boardState) {
526
564
  this.emit('error', `Board not found: ${boardId}`);
@@ -531,7 +569,7 @@ export class PlanExecutor extends EventEmitter {
531
569
  return null;
532
570
  }
533
571
  if (boardState.board.status === 'draft') {
534
- this.activateBoard(pmDir, boardId);
572
+ await this.activateBoard(pmDir, boardId);
535
573
  } else if (boardState.board.status !== 'active') {
536
574
  this.emit('error', `Board ${boardId} is not active (status: ${boardState.board.status})`);
537
575
  return null;
@@ -554,17 +592,19 @@ export class PlanExecutor extends EventEmitter {
554
592
  }
555
593
 
556
594
  /** Activate a draft board by updating its status in board.md. */
557
- private activateBoard(pmDir: string, boardId: string): void {
595
+ private async activateBoard(pmDir: string, boardId: string): Promise<void> {
558
596
  const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
559
597
  if (!existsSync(boardMdPath)) return;
560
598
  try {
561
- const content = readFileSync(boardMdPath, 'utf-8');
562
- writeFileSync(boardMdPath, replaceFrontMatterField(content, 'status', 'active'), 'utf-8');
563
- } catch { /* non-fatal — pickReadyIssues will re-check */ }
599
+ const content = await readFile(boardMdPath, 'utf-8');
600
+ await writeFile(boardMdPath, replaceFrontMatterField(content, 'status', 'active'), 'utf-8');
601
+ } catch (err) {
602
+ this.emit('output', { issueId: 'system', text: `Warning: failed to activate board ${boardId}: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
603
+ }
564
604
  }
565
605
 
566
606
  /** Check if all issues in a board are done and mark board as completed. */
567
- private tryCompleteBoardIfDone(pmDir: string, boardId: string, issues: Issue[]): void {
607
+ private async tryCompleteBoardIfDone(pmDir: string, boardId: string, issues: Issue[]): Promise<void> {
568
608
  const allDone = issues.length > 0 && issues.every(i => i.status === 'done' || i.status === 'cancelled');
569
609
  if (!allDone) return;
570
610
 
@@ -572,11 +612,13 @@ export class PlanExecutor extends EventEmitter {
572
612
  if (!existsSync(boardMdPath)) return;
573
613
 
574
614
  try {
575
- let content = readFileSync(boardMdPath, 'utf-8');
615
+ let content = await readFile(boardMdPath, 'utf-8');
576
616
  content = replaceFrontMatterField(content, 'status', 'completed');
577
617
  content = replaceFrontMatterField(content, 'completed_at', `"${new Date().toISOString()}"`);
578
- writeFileSync(boardMdPath, content, 'utf-8');
579
- } catch { /* non-fatal */ }
618
+ await writeFile(boardMdPath, content, 'utf-8');
619
+ } catch (err) {
620
+ 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 });
621
+ }
580
622
  }
581
623
 
582
624
  private resolveActiveBoardId(): string | null {
@@ -601,54 +643,95 @@ export class PlanExecutor extends EventEmitter {
601
643
  return this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
602
644
  }
603
645
 
604
- private revertIncompleteIssues(issues: Issue[]): void {
646
+ /** Detect issues stuck in non-terminal states with no path to completion. */
647
+ private detectDeadState(issues: Issue[]): string | null {
648
+ const nonEpic = issues.filter(i => i.type !== 'epic');
649
+ const terminalStatuses = new Set(['done', 'cancelled']);
650
+ const stuck = nonEpic.filter(i => !terminalStatuses.has(i.status) && i.status !== 'todo');
651
+
652
+ if (stuck.length === 0) return null;
653
+
654
+ const stuckIds = stuck.map(i => `${i.id} (${i.status})`).join(', ');
655
+
656
+ const issueByPath = new Map(issues.map(i => [i.path, i]));
657
+ const blockedByStuck = nonEpic.filter(i => {
658
+ if (i.status !== 'todo') return false;
659
+ return i.blockedBy.some(bp => {
660
+ const blocker = issueByPath.get(bp);
661
+ return blocker && !terminalStatuses.has(blocker.status);
662
+ });
663
+ });
664
+ const blockedIds = blockedByStuck.map(i => i.id).join(', ');
665
+
666
+ return `Board stuck: ${stuckIds} cannot progress${blockedIds ? `. Blocking: ${blockedIds}` : ''}`;
667
+ }
668
+
669
+ private async revertIncompleteIssues(issues: Issue[]): Promise<void> {
605
670
  const pmDir = this.pmDir;
606
671
  if (!pmDir) return;
607
672
  for (const issue of issues) {
608
- const fullPath = join(pmDir, issue.path);
673
+ const fullPath = this.validateIssuePath(issue.path, pmDir);
609
674
  try {
610
- const content = readFileSync(fullPath, 'utf-8');
675
+ const content = await readFile(fullPath, 'utf-8');
611
676
  if (content.match(/^status:\s*in_progress$/m)) {
612
- this.updateIssueFrontMatter(issue.path, issue.status);
677
+ await this.updateIssueFrontMatter(issue.path, issue.status);
613
678
  }
614
- } catch { /* file may be gone */ }
679
+ } catch (err) {
680
+ this.emit('output', { issueId: issue.id, text: `Warning: failed to revert issue status: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
681
+ }
615
682
  }
616
683
  }
617
684
 
618
- private updateIssueFrontMatter(issuePath: string, newStatus: string): void {
685
+ private async appendCancellationNote(issue: Issue, pmDir: string, reason: string): Promise<void> {
686
+ const fullPath = this.validateIssuePath(issue.path, pmDir);
687
+ try {
688
+ let content = await readFile(fullPath, 'utf-8');
689
+ const entry = `- Cancelled (${new Date().toISOString().split('T')[0]}): ${reason}`;
690
+ if (content.includes('## Activity')) {
691
+ content = content.replace(/## Activity/, `## Activity\n${entry}`);
692
+ } else {
693
+ content += `\n\n## Activity\n${entry}`;
694
+ }
695
+ await writeFile(fullPath, content, 'utf-8');
696
+ } catch (err) {
697
+ this.emit('output', { issueId: issue.id, text: `Warning: failed to append cancellation note: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
698
+ }
699
+ }
700
+
701
+ private async updateIssueFrontMatter(issuePath: string, newStatus: string): Promise<void> {
619
702
  const pmDir = this.pmDir;
620
703
  if (!pmDir) return;
621
704
  try {
622
- const fullPath = join(pmDir, issuePath);
623
- setFrontMatterField(fullPath, 'status', newStatus);
705
+ const fullPath = this.validateIssuePath(issuePath, pmDir);
706
+ await setFrontMatterFieldAsync(fullPath, 'status', newStatus);
624
707
 
625
- // Check off all acceptance criteria when marking done
626
708
  if (newStatus === 'done') {
627
- const content = readFileSync(fullPath, 'utf-8');
709
+ const content = await readFile(fullPath, 'utf-8');
628
710
  const updated = checkAllAcceptanceCriteria(content);
629
- if (updated !== content) writeFileSync(fullPath, updated, 'utf-8');
711
+ if (updated !== content) await writeFile(fullPath, updated, 'utf-8');
630
712
  }
631
- } catch { /* file may have been moved */ }
713
+ } catch (err) {
714
+ 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 });
715
+ }
632
716
  }
633
717
 
634
- private ensureOutputDirs(): void {
718
+ private async ensureOutputDirs(): Promise<void> {
635
719
  if (this.boardDir) {
636
720
  const boardOutDir = join(this.boardDir, 'out');
637
- if (!existsSync(boardOutDir)) mkdirSync(boardOutDir, { recursive: true });
721
+ if (!existsSync(boardOutDir)) await mkdir(boardOutDir, { recursive: true });
638
722
  } else {
639
723
  const pmDir = this.pmDir;
640
724
  if (pmDir) {
641
725
  const outDir = join(pmDir, 'out');
642
- if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
726
+ if (!existsSync(outDir)) await mkdir(outDir, { recursive: true });
643
727
  }
644
728
  }
645
729
  }
646
730
 
647
- private appendProgressEntry(issues: Issue[], waveStart: number): void {
731
+ private async appendProgressEntry(issues: Issue[], waveStart: number): Promise<void> {
648
732
  const pmDir = this.pmDir;
649
733
  if (!pmDir) return;
650
734
 
651
- // Board-scoped progress log
652
735
  const progressPath = this.boardDir
653
736
  ? join(this.boardDir, 'progress.md')
654
737
  : join(pmDir, 'progress.md');
@@ -660,7 +743,7 @@ export class PlanExecutor extends EventEmitter {
660
743
  const failed: string[] = [];
661
744
  for (const issue of issues) {
662
745
  try {
663
- const content = readFileSync(join(pmDir, issue.path), 'utf-8');
746
+ const content = await readFile(this.validateIssuePath(issue.path, pmDir), 'utf-8');
664
747
  const statusMatch = content.match(/^status:\s*(\S+)/m);
665
748
  if (statusMatch?.[1] === 'done') {
666
749
  completed.push(issue.id);
@@ -684,18 +767,19 @@ export class PlanExecutor extends EventEmitter {
684
767
  }
685
768
  lines.push('');
686
769
 
687
- this.writeProgressLines(progressPath, lines);
770
+ await this.writeProgressLines(progressPath, lines);
688
771
  }
689
772
 
690
- private writeProgressLines(filePath: string, lines: string[]): void {
773
+ private async writeProgressLines(filePath: string, lines: string[]): Promise<void> {
691
774
  try {
692
775
  if (existsSync(filePath)) {
693
- const existing = readFileSync(filePath, 'utf-8');
694
- writeFileSync(filePath, `${existing.trimEnd()}\n${lines.join('\n')}`, 'utf-8');
776
+ await appendFile(filePath, `\n${lines.join('\n')}`, 'utf-8');
695
777
  } else {
696
- writeFileSync(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
778
+ await writeFile(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
697
779
  }
698
- } catch { /* non-fatal */ }
780
+ } catch (err) {
781
+ this.emit('output', { issueId: 'system', text: `Warning: failed to write progress log: ${err instanceof Error ? err.message : String(err)}`, boardId: this.boardId ?? null });
782
+ }
699
783
  }
700
784
 
701
785
  /** Resolve the active board's directory path for outputs, reviews, and progress. */
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { readFileSync, writeFileSync } from 'node:fs';
13
+ import { readFile, writeFile } from 'node:fs/promises';
13
14
 
14
15
  /**
15
16
  * Replace a field value in a raw YAML string (no --- delimiters).
@@ -47,6 +48,12 @@ export function setFrontMatterField(filePath: string, field: string, value: stri
47
48
  writeFileSync(filePath, updated, 'utf-8');
48
49
  }
49
50
 
51
+ export async function setFrontMatterFieldAsync(filePath: string, field: string, value: string): Promise<void> {
52
+ const content = await readFile(filePath, 'utf-8');
53
+ const updated = replaceFrontMatterField(content, field, value);
54
+ await writeFile(filePath, updated, 'utf-8');
55
+ }
56
+
50
57
  /**
51
58
  * Check off all unchecked acceptance criteria checkboxes in a markdown string.
52
59
  * Only modifies checkboxes within the "## Acceptance Criteria" section.
@@ -0,0 +1,21 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ import type { Issue } from './types.js';
5
+
6
+ /**
7
+ * Determine whether an issue is a code task (modifies source files) or a
8
+ * document task (produces written output like research, design, plans).
9
+ *
10
+ * Uses the issue's `outputType` field when explicitly set. Falls back to
11
+ * inferring from `filesToModify` — entries prefixed with "Output:" are
12
+ * output path hints, not source files to edit.
13
+ */
14
+ export function resolveIsCodeTask(issue: Issue): boolean {
15
+ if (issue.outputType === 'code') return true;
16
+ if (issue.outputType === 'document') return false;
17
+
18
+ // auto: infer from filesToModify, filtering out Output:-prefixed entries
19
+ const codeFiles = issue.filesToModify.filter(f => !f.match(/^Output:/i));
20
+ return codeFiles.length > 0;
21
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { join } from 'node:path';
12
+ import { resolveIsCodeTask } from './issue-classification.js';
12
13
  import type { Issue } from './types.js';
13
14
 
14
15
  export interface IssuePromptOptions {
@@ -33,8 +34,11 @@ export function buildIssuePrompt(options: IssuePromptOptions): string {
33
34
  .map(c => `- [${c.checked ? 'x' : ' '}] ${c.text}`)
34
35
  .join('\n');
35
36
 
36
- const files = issue.filesToModify.length > 0
37
- ? `\n## Files to Modify\n${issue.filesToModify.map(f => `- ${f}`).join('\n')}`
37
+ const isCode = resolveIsCodeTask(issue);
38
+ const codeFiles = isCode ? issue.filesToModify.filter(f => !f.match(/^Output:/i)) : [];
39
+
40
+ const files = codeFiles.length > 0
41
+ ? `\n## Files to Modify\n${codeFiles.map(f => `- ${f}`).join('\n')}`
38
42
  : '';
39
43
 
40
44
  const predecessorDocs = resolvePredecessorDocs(issue, existingDocs);
@@ -69,7 +73,7 @@ ${files}${predecessorSection}
69
73
  ## Your Task
70
74
 
71
75
  1. Read the full issue spec at ${pmDir ? join(pmDir, issue.path) : issue.path}
72
- ${issue.filesToModify.length > 0 ? `2. **Implement the code changes** in the source files listed under "Files to Modify". You MUST edit or create the actual source code files — the acceptance criteria describe what the code must do, not what to document. Read each target file first, then make the changes using Edit or Write.
76
+ ${isCode ? `2. **Implement the code changes** in the source files listed under "Files to Modify". You MUST edit or create the actual source code files — the acceptance criteria describe what the code must do, not what to document. Read each target file first, then make the changes using Edit or Write.
73
77
  3. After implementation, write a brief summary of what you changed to **${outputPath}**${predecessorDocs.length > 0 ? ' — this is the handoff artifact for downstream issues' : ''}
74
78
  4. After writing output, update the issue front matter: change \`status: in_progress\` to \`status: in_review\`` : `2. Execute all acceptance criteria listed above
75
79
  3. Write your output and results to **${outputPath}** — this is the handoff artifact for downstream issues
@@ -79,7 +83,7 @@ ${issue.filesToModify.length > 0 ? `2. **Implement the code changes** in the sou
79
83
 
80
84
  - Stay within this issue's scope. Do not modify files outside your assigned scope.
81
85
  - The orchestrator manages STATE.md separately — do not edit STATE.md.
82
- ${issue.filesToModify.length > 0 ? `- The output file is a summary of work done, NOT a substitute for implementation. You must modify the actual source code files listed in "Files to Modify". A review gate will verify the source files were changed.` : `- Write all significant output to ${outDir}/ so downstream issues can reference it.`}
86
+ ${isCode ? `- The output file is a summary of work done, NOT a substitute for implementation. You must modify the actual source code files listed in "Files to Modify". A review gate will verify the source files were changed.` : `- Write all significant output to ${outDir}/ so downstream issues can reference it.`}
83
87
  - If you cannot complete the issue, leave status as \`in_progress\` and document what blocked you in the output file.`;
84
88
  }
85
89