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
@@ -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 { isAbsolute, join, relative, 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,16 @@ 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 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;
102
+ }
103
+
93
104
  getStatus(): ExecutionStatus { return this.status; }
94
105
  getMetrics(): ExecutionMetrics { return { ...this.metrics }; }
95
106
 
@@ -129,33 +140,40 @@ export class PlanExecutor extends EventEmitter {
129
140
  this.pmDir = resolvePmDir(this.workingDir);
130
141
  this.boardDir = this.resolveBoardDir();
131
142
 
132
- this.recoverStaleIssues();
143
+ await this.recoverStaleIssues();
133
144
 
134
145
  const stallResult = await this.runWaveLoop();
135
146
 
136
147
  this.metrics.totalDuration = Date.now() - startTime;
137
148
 
138
- if (stallResult === 'stalled') {
149
+ if (stallResult === 'stalled' || stallResult === 'dead') {
139
150
  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.`);
151
+ if (stallResult === 'stalled') {
152
+ this.emit('error', `Execution stalled: ${MAX_CONSECUTIVE_EMPTY_WAVES} consecutive waves completed zero issues. Issues may be stuck in review or failing repeatedly.`);
153
+ }
141
154
  } else if (this.shouldPause) {
142
155
  this.status = 'paused';
143
156
  } else if (this.shouldStop) {
144
157
  this.status = 'idle';
158
+ // Emit complete so clients can transition out of 'stopping' — metrics are broadcast by the handler.
159
+ this.emit('complete', 'Stopped by user');
145
160
  } else {
146
161
  this.status = 'complete';
147
162
  }
148
163
  this.emit('statusChanged', this.status);
149
164
  }
150
165
 
151
- /** Run waves until done, paused, stopped, or stalled. Returns 'stalled' if zero-completion cap hit. */
152
- private async runWaveLoop(): Promise<'done' | 'stalled'> {
166
+ /** Run waves until done, paused, stopped, or stalled. */
167
+ private async runWaveLoop(): Promise<'done' | 'stalled' | 'dead'> {
153
168
  let consecutiveZeroCompletions = 0;
154
- const maxParallel = this.getBoardMaxParallelAgents();
169
+ const maxParallel = await this.getBoardMaxParallelAgents();
155
170
 
156
171
  while (!this.shouldStop && !this.shouldPause) {
157
- const readyIssues = this.pickReadyIssues();
158
- if (readyIssues.length === 0) break;
172
+ const readyIssues = await this.pickReadyIssues();
173
+ if (readyIssues.length === 0) {
174
+ // pickReadyIssues emits 'error' for dead state, 'complete' otherwise — check if dead
175
+ return await this.hasDeadIssues() ? 'dead' : 'done';
176
+ }
159
177
 
160
178
  const completedCount = await this.executeWave(readyIssues.slice(0, maxParallel));
161
179
 
@@ -169,6 +187,18 @@ export class PlanExecutor extends EventEmitter {
169
187
  return 'done';
170
188
  }
171
189
 
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
+
172
202
  pause(): void { this.shouldPause = true; }
173
203
  stop(): void {
174
204
  this.shouldStop = true;
@@ -199,11 +229,11 @@ export class PlanExecutor extends EventEmitter {
199
229
  // Create abort controller for this wave — stop() will abort it
200
230
  this.waveAbortController = new AbortController();
201
231
 
202
- this.ensureOutputDirs();
232
+ await this.ensureOutputDirs();
203
233
  this.configInstaller.installPermissions();
204
234
 
205
235
  for (const issue of issues) {
206
- this.updateIssueFrontMatter(issue.path, 'in_progress');
236
+ await this.updateIssueFrontMatter(issue.path, 'in_progress');
207
237
  }
208
238
 
209
239
  const existingDocs = listExistingDocs(this.workingDir, this.boardDir);
@@ -234,13 +264,13 @@ export class PlanExecutor extends EventEmitter {
234
264
  issueIds: waveIds,
235
265
  error: error instanceof Error ? error.message : String(error),
236
266
  });
237
- this.revertIncompleteIssues(issues);
267
+ await this.revertIncompleteIssues(issues);
238
268
  } finally {
239
269
  this.configInstaller.uninstallPermissions();
240
270
  }
241
271
 
242
272
  this.waveAbortController = null;
243
- this.finalizeWave(issues, waveStart, waveLabel);
273
+ await this.finalizeWave(issues, waveStart, waveLabel);
244
274
  this.metrics.currentWaveIds = [];
245
275
  return completedCount;
246
276
  }
@@ -288,7 +318,7 @@ export class PlanExecutor extends EventEmitter {
288
318
  * Post-wave operations wrapped individually so a failure in one
289
319
  * doesn't prevent the others or kill the while loop in start().
290
320
  */
291
- private finalizeWave(issues: Issue[], waveStart: number, waveLabel: string): void {
321
+ private async finalizeWave(issues: Issue[], waveStart: number, waveLabel: string): Promise<void> {
292
322
  try {
293
323
  reconcileState(this.workingDir, this.boardId ?? undefined);
294
324
  this.emit('stateUpdated');
@@ -311,7 +341,7 @@ export class PlanExecutor extends EventEmitter {
311
341
  }
312
342
 
313
343
  try {
314
- this.appendProgressEntry(issues, waveStart);
344
+ await this.appendProgressEntry(issues, waveStart);
315
345
  } catch (err) {
316
346
  this.emit('output', {
317
347
  issueId: waveLabel,
@@ -334,16 +364,16 @@ export class PlanExecutor extends EventEmitter {
334
364
  let completed = 0;
335
365
 
336
366
  for (const issue of issues) {
337
- const fullPath = join(pmDir, issue.path);
367
+ const fullPath = this.validateIssuePath(issue.path, pmDir);
338
368
  try {
339
- const content = readFileSync(fullPath, 'utf-8');
369
+ const content = await readFile(fullPath, 'utf-8');
340
370
  const statusMatch = content.match(/^status:\s*(\S+)/m);
341
371
  const currentStatus = statusMatch?.[1] ?? 'unknown';
342
372
 
343
373
  if (currentStatus === 'in_review' || currentStatus === 'done') {
344
374
  if (issue.reviewGate === 'none') {
345
375
  // Skip review gate — mark done directly
346
- this.updateIssueFrontMatter(issue.path, 'done');
376
+ await this.updateIssueFrontMatter(issue.path, 'done');
347
377
  this.metrics.issuesCompleted++;
348
378
  this.emit('issueCompleted', issue);
349
379
  completed++;
@@ -351,7 +381,7 @@ export class PlanExecutor extends EventEmitter {
351
381
  completed += await this.runReviewGate(issue, pmDir);
352
382
  }
353
383
  } else {
354
- this.updateIssueFrontMatter(issue.path, issue.status);
384
+ await this.updateIssueFrontMatter(issue.path, issue.status);
355
385
  this.emit('issueError', {
356
386
  issueId: issue.id,
357
387
  error: 'Issue did not complete during wave execution',
@@ -370,38 +400,44 @@ export class PlanExecutor extends EventEmitter {
370
400
  const reviewDir = this.boardDir ?? pmDir;
371
401
  const attempts = getReviewAttemptCount(reviewDir, issue);
372
402
  if (attempts >= MAX_REVIEW_ATTEMPTS) {
373
- this.updateIssueFrontMatter(issue.path, 'in_review');
403
+ await this.updateIssueFrontMatter(issue.path, 'cancelled');
404
+ await this.appendCancellationNote(issue, pmDir, `Cancelled after ${MAX_REVIEW_ATTEMPTS} failed reviews — issue may need restructuring`);
374
405
  this.emit('reviewProgress', { issueId: issue.id, status: 'max_attempts' });
375
- this.emit('output', { issueId: issue.id, text: 'Review: max attempts reached, keeping in review' });
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` });
376
412
  return 0;
377
413
  }
378
414
 
379
- this.updateIssueFrontMatter(issue.path, 'in_review');
415
+ await this.updateIssueFrontMatter(issue.path, 'in_review');
380
416
  this.emit('reviewProgress', { issueId: issue.id, status: 'reviewing' });
381
417
 
382
418
  const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
383
419
  const result = await reviewIssue({
384
- workingDir: this.workingDir,
420
+ workingDir: this.executionDir || this.workingDir,
385
421
  issue,
386
422
  pmDir,
387
423
  outputPath,
388
424
  onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
389
425
  logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
390
- reviewCriteria: this.getBoardReviewCriteria(),
426
+ reviewCriteria: await this.getBoardReviewCriteria(),
391
427
  boardDir: this.boardDir,
392
428
  extraEnv: this.extraEnv,
393
429
  });
394
430
  persistReviewResult(reviewDir, issue, result);
395
431
 
396
432
  if (result.passed) {
397
- this.updateIssueFrontMatter(issue.path, 'done');
433
+ await this.updateIssueFrontMatter(issue.path, 'done');
398
434
  this.metrics.issuesCompleted++;
399
435
  this.emit('reviewProgress', { issueId: issue.id, status: 'passed' });
400
436
  this.emit('issueCompleted', issue);
401
437
  return 1;
402
438
  }
403
439
 
404
- this.updateIssueFrontMatter(issue.path, 'todo');
440
+ await this.updateIssueFrontMatter(issue.path, 'todo');
405
441
  appendReviewFeedback(pmDir, issue, result);
406
442
  this.emit('reviewProgress', { issueId: issue.id, status: 'failed' });
407
443
  this.emit('issueError', {
@@ -419,13 +455,13 @@ export class PlanExecutor extends EventEmitter {
419
455
  * these issues block the dependency graph and cause the executor to
420
456
  * find zero ready issues, making "Implement" appear to do nothing.
421
457
  */
422
- private recoverStaleIssues(): void {
458
+ private async recoverStaleIssues(): Promise<void> {
423
459
  const pmDir = this.pmDir;
424
460
  if (!pmDir) return;
425
461
 
426
462
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
427
463
  const issues = effectiveBoardId
428
- ? this.loadBoardIssues(pmDir, effectiveBoardId)
464
+ ? await this.loadBoardIssues(pmDir, effectiveBoardId)
429
465
  : this.loadProjectIssues();
430
466
 
431
467
  if (!issues) return;
@@ -436,7 +472,7 @@ export class PlanExecutor extends EventEmitter {
436
472
  for (const issue of issues) {
437
473
  if (issue.type === 'epic') continue;
438
474
  if (staleStatuses.has(issue.status)) {
439
- this.updateIssueFrontMatter(issue.path, 'todo');
475
+ await this.updateIssueFrontMatter(issue.path, 'todo');
440
476
  recovered.push(`${issue.id} (${issue.status} → todo)`);
441
477
  }
442
478
  }
@@ -453,28 +489,28 @@ export class PlanExecutor extends EventEmitter {
453
489
  // ── Helpers ──────────────────────────────────────────────────
454
490
 
455
491
  /** Read the board's maxParallelAgents setting, falling back to default. */
456
- private getBoardMaxParallelAgents(): number {
492
+ private async getBoardMaxParallelAgents(): Promise<number> {
457
493
  const pmDir = this.pmDir;
458
494
  if (!pmDir) return DEFAULT_MAX_PARALLEL_AGENTS;
459
495
 
460
496
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
461
497
  if (!effectiveBoardId) return DEFAULT_MAX_PARALLEL_AGENTS;
462
498
 
463
- // Read only board.md — avoids parsing STATE.md and all backlog issues just for one setting
464
499
  const boardMdPath = join(pmDir, 'boards', effectiveBoardId, 'board.md');
465
500
  if (!existsSync(boardMdPath)) return DEFAULT_MAX_PARALLEL_AGENTS;
466
501
 
467
502
  try {
468
- const content = readFileSync(boardMdPath, 'utf-8');
503
+ const content = await readFile(boardMdPath, 'utf-8');
469
504
  const match = content.match(/^max_parallel_agents:\s*(\d+)/m);
470
505
  return match ? Math.max(1, Math.min(Number(match[1]), 10)) : DEFAULT_MAX_PARALLEL_AGENTS;
471
- } catch {
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 });
472
508
  return DEFAULT_MAX_PARALLEL_AGENTS;
473
509
  }
474
510
  }
475
511
 
476
512
  /** Read the board's custom review criteria, if set. */
477
- private getBoardReviewCriteria(): string | undefined {
513
+ private async getBoardReviewCriteria(): Promise<string | undefined> {
478
514
  const pmDir = this.pmDir;
479
515
  if (!pmDir) return undefined;
480
516
 
@@ -485,17 +521,18 @@ export class PlanExecutor extends EventEmitter {
485
521
  if (!existsSync(boardMdPath)) return undefined;
486
522
 
487
523
  try {
488
- const content = readFileSync(boardMdPath, 'utf-8');
524
+ const content = await readFile(boardMdPath, 'utf-8');
489
525
  const match = content.match(/^review_criteria:\s*"(.+)"/m);
490
526
  if (!match) return undefined;
491
527
  const raw = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n').trim();
492
528
  return raw || undefined;
493
- } catch {
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 });
494
531
  return undefined;
495
532
  }
496
533
  }
497
534
 
498
- private pickReadyIssues(): Issue[] {
535
+ private async pickReadyIssues(): Promise<Issue[]> {
499
536
  const pmDir = this.pmDir;
500
537
  if (!pmDir) {
501
538
  this.emit('error', 'No PM directory found');
@@ -504,23 +541,28 @@ export class PlanExecutor extends EventEmitter {
504
541
 
505
542
  const effectiveBoardId = this.boardId ?? this.resolveActiveBoardId();
506
543
  const issues = effectiveBoardId
507
- ? this.loadBoardIssues(pmDir, effectiveBoardId)
544
+ ? await this.loadBoardIssues(pmDir, effectiveBoardId)
508
545
  : this.loadProjectIssues();
509
546
 
510
547
  if (!issues) return [];
511
548
 
512
549
  const readyIssues = resolveReadyToWork(issues, this.epicScope ?? undefined);
513
550
  if (readyIssues.length === 0) {
514
- this.emit('complete', this.buildCompletionReason(issues));
515
- if (effectiveBoardId) {
516
- this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
551
+ const deadState = this.detectDeadState(issues);
552
+ if (deadState) {
553
+ this.emit('error', deadState);
554
+ } else {
555
+ this.emit('complete', this.buildCompletionReason(issues));
556
+ if (effectiveBoardId) {
557
+ await this.tryCompleteBoardIfDone(pmDir, effectiveBoardId, issues);
558
+ }
517
559
  }
518
560
  }
519
561
  return readyIssues;
520
562
  }
521
563
 
522
564
  /** Load issues from a specific board, auto-activating draft boards. Returns null on error. */
523
- private loadBoardIssues(pmDir: string, boardId: string): Issue[] | null {
565
+ private async loadBoardIssues(pmDir: string, boardId: string): Promise<Issue[] | null> {
524
566
  const boardState = parseBoardDirectory(pmDir, boardId);
525
567
  if (!boardState) {
526
568
  this.emit('error', `Board not found: ${boardId}`);
@@ -531,7 +573,7 @@ export class PlanExecutor extends EventEmitter {
531
573
  return null;
532
574
  }
533
575
  if (boardState.board.status === 'draft') {
534
- this.activateBoard(pmDir, boardId);
576
+ await this.activateBoard(pmDir, boardId);
535
577
  } else if (boardState.board.status !== 'active') {
536
578
  this.emit('error', `Board ${boardId} is not active (status: ${boardState.board.status})`);
537
579
  return null;
@@ -554,17 +596,19 @@ export class PlanExecutor extends EventEmitter {
554
596
  }
555
597
 
556
598
  /** Activate a draft board by updating its status in board.md. */
557
- private activateBoard(pmDir: string, boardId: string): void {
599
+ private async activateBoard(pmDir: string, boardId: string): Promise<void> {
558
600
  const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
559
601
  if (!existsSync(boardMdPath)) return;
560
602
  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 */ }
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
+ }
564
608
  }
565
609
 
566
610
  /** Check if all issues in a board are done and mark board as completed. */
567
- private tryCompleteBoardIfDone(pmDir: string, boardId: string, issues: Issue[]): void {
611
+ private async tryCompleteBoardIfDone(pmDir: string, boardId: string, issues: Issue[]): Promise<void> {
568
612
  const allDone = issues.length > 0 && issues.every(i => i.status === 'done' || i.status === 'cancelled');
569
613
  if (!allDone) return;
570
614
 
@@ -572,11 +616,13 @@ export class PlanExecutor extends EventEmitter {
572
616
  if (!existsSync(boardMdPath)) return;
573
617
 
574
618
  try {
575
- let content = readFileSync(boardMdPath, 'utf-8');
619
+ let content = await readFile(boardMdPath, 'utf-8');
576
620
  content = replaceFrontMatterField(content, 'status', 'completed');
577
621
  content = replaceFrontMatterField(content, 'completed_at', `"${new Date().toISOString()}"`);
578
- writeFileSync(boardMdPath, content, 'utf-8');
579
- } catch { /* non-fatal */ }
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
+ }
580
626
  }
581
627
 
582
628
  private resolveActiveBoardId(): string | null {
@@ -601,54 +647,95 @@ export class PlanExecutor extends EventEmitter {
601
647
  return this.epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
602
648
  }
603
649
 
604
- private revertIncompleteIssues(issues: Issue[]): void {
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> {
605
674
  const pmDir = this.pmDir;
606
675
  if (!pmDir) return;
607
676
  for (const issue of issues) {
608
- const fullPath = join(pmDir, issue.path);
677
+ const fullPath = this.validateIssuePath(issue.path, pmDir);
609
678
  try {
610
- const content = readFileSync(fullPath, 'utf-8');
679
+ const content = await readFile(fullPath, 'utf-8');
611
680
  if (content.match(/^status:\s*in_progress$/m)) {
612
- this.updateIssueFrontMatter(issue.path, issue.status);
681
+ await this.updateIssueFrontMatter(issue.path, issue.status);
613
682
  }
614
- } catch { /* file may be gone */ }
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
+ }
615
686
  }
616
687
  }
617
688
 
618
- private updateIssueFrontMatter(issuePath: string, newStatus: string): void {
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> {
619
706
  const pmDir = this.pmDir;
620
707
  if (!pmDir) return;
621
708
  try {
622
- const fullPath = join(pmDir, issuePath);
623
- setFrontMatterField(fullPath, 'status', newStatus);
709
+ const fullPath = this.validateIssuePath(issuePath, pmDir);
710
+ await setFrontMatterFieldAsync(fullPath, 'status', newStatus);
624
711
 
625
- // Check off all acceptance criteria when marking done
626
712
  if (newStatus === 'done') {
627
- const content = readFileSync(fullPath, 'utf-8');
713
+ const content = await readFile(fullPath, 'utf-8');
628
714
  const updated = checkAllAcceptanceCriteria(content);
629
- if (updated !== content) writeFileSync(fullPath, updated, 'utf-8');
715
+ if (updated !== content) await writeFile(fullPath, updated, 'utf-8');
630
716
  }
631
- } catch { /* file may have been moved */ }
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
+ }
632
720
  }
633
721
 
634
- private ensureOutputDirs(): void {
722
+ private async ensureOutputDirs(): Promise<void> {
635
723
  if (this.boardDir) {
636
724
  const boardOutDir = join(this.boardDir, 'out');
637
- if (!existsSync(boardOutDir)) mkdirSync(boardOutDir, { recursive: true });
725
+ if (!existsSync(boardOutDir)) await mkdir(boardOutDir, { recursive: true });
638
726
  } else {
639
727
  const pmDir = this.pmDir;
640
728
  if (pmDir) {
641
729
  const outDir = join(pmDir, 'out');
642
- if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
730
+ if (!existsSync(outDir)) await mkdir(outDir, { recursive: true });
643
731
  }
644
732
  }
645
733
  }
646
734
 
647
- private appendProgressEntry(issues: Issue[], waveStart: number): void {
735
+ private async appendProgressEntry(issues: Issue[], waveStart: number): Promise<void> {
648
736
  const pmDir = this.pmDir;
649
737
  if (!pmDir) return;
650
738
 
651
- // Board-scoped progress log
652
739
  const progressPath = this.boardDir
653
740
  ? join(this.boardDir, 'progress.md')
654
741
  : join(pmDir, 'progress.md');
@@ -660,7 +747,7 @@ export class PlanExecutor extends EventEmitter {
660
747
  const failed: string[] = [];
661
748
  for (const issue of issues) {
662
749
  try {
663
- const content = readFileSync(join(pmDir, issue.path), 'utf-8');
750
+ const content = await readFile(this.validateIssuePath(issue.path, pmDir), 'utf-8');
664
751
  const statusMatch = content.match(/^status:\s*(\S+)/m);
665
752
  if (statusMatch?.[1] === 'done') {
666
753
  completed.push(issue.id);
@@ -684,18 +771,19 @@ export class PlanExecutor extends EventEmitter {
684
771
  }
685
772
  lines.push('');
686
773
 
687
- this.writeProgressLines(progressPath, lines);
774
+ await this.writeProgressLines(progressPath, lines);
688
775
  }
689
776
 
690
- private writeProgressLines(filePath: string, lines: string[]): void {
777
+ private async writeProgressLines(filePath: string, lines: string[]): Promise<void> {
691
778
  try {
692
779
  if (existsSync(filePath)) {
693
- const existing = readFileSync(filePath, 'utf-8');
694
- writeFileSync(filePath, `${existing.trimEnd()}\n${lines.join('\n')}`, 'utf-8');
780
+ await appendFile(filePath, `\n${lines.join('\n')}`, 'utf-8');
695
781
  } else {
696
- writeFileSync(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
782
+ await writeFile(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
697
783
  }
698
- } catch { /* non-fatal */ }
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
+ }
699
787
  }
700
788
 
701
789
  /** 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