mstro-app 0.4.20 → 0.4.22

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 (177) hide show
  1. package/README.md +66 -0
  2. package/dist/server/cli/headless/claude-invoker-process.js +1 -1
  3. package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
  4. package/dist/server/cli/headless/headless-logger.js +1 -1
  5. package/dist/server/cli/headless/headless-logger.js.map +1 -1
  6. package/dist/server/cli/headless/mcp-config.d.ts +1 -1
  7. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
  8. package/dist/server/cli/headless/mcp-config.js +4 -1
  9. package/dist/server/cli/headless/mcp-config.js.map +1 -1
  10. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  11. package/dist/server/cli/headless/runner.js +1 -0
  12. package/dist/server/cli/headless/runner.js.map +1 -1
  13. package/dist/server/cli/headless/types.d.ts +4 -1
  14. package/dist/server/cli/headless/types.d.ts.map +1 -1
  15. package/dist/server/index.js +9 -1
  16. package/dist/server/index.js.map +1 -1
  17. package/dist/server/mcp/bouncer-integration.d.ts +2 -2
  18. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
  19. package/dist/server/mcp/bouncer-integration.js +20 -20
  20. package/dist/server/mcp/bouncer-integration.js.map +1 -1
  21. package/dist/server/mcp/security-analysis.d.ts +6 -0
  22. package/dist/server/mcp/security-analysis.d.ts.map +1 -1
  23. package/dist/server/mcp/security-analysis.js +16 -1
  24. package/dist/server/mcp/security-analysis.js.map +1 -1
  25. package/dist/server/mcp/security-patterns.d.ts +8 -0
  26. package/dist/server/mcp/security-patterns.d.ts.map +1 -1
  27. package/dist/server/mcp/security-patterns.js +47 -2
  28. package/dist/server/mcp/security-patterns.js.map +1 -1
  29. package/dist/server/services/deploy/ai-broker.d.ts +63 -0
  30. package/dist/server/services/deploy/ai-broker.d.ts.map +1 -0
  31. package/dist/server/services/deploy/ai-broker.js +360 -0
  32. package/dist/server/services/deploy/ai-broker.js.map +1 -0
  33. package/dist/server/services/deploy/board-execution-handler.d.ts +114 -0
  34. package/dist/server/services/deploy/board-execution-handler.d.ts.map +1 -0
  35. package/dist/server/services/deploy/board-execution-handler.js +621 -0
  36. package/dist/server/services/deploy/board-execution-handler.js.map +1 -0
  37. package/dist/server/services/deploy/credentials.d.ts +35 -0
  38. package/dist/server/services/deploy/credentials.d.ts.map +1 -0
  39. package/dist/server/services/deploy/credentials.js +177 -0
  40. package/dist/server/services/deploy/credentials.js.map +1 -0
  41. package/dist/server/services/deploy/deploy-ai-service.d.ts +107 -0
  42. package/dist/server/services/deploy/deploy-ai-service.d.ts.map +1 -0
  43. package/dist/server/services/deploy/deploy-ai-service.js +294 -0
  44. package/dist/server/services/deploy/deploy-ai-service.js.map +1 -0
  45. package/dist/server/services/deploy/headless-session-handler.d.ts +94 -0
  46. package/dist/server/services/deploy/headless-session-handler.d.ts.map +1 -0
  47. package/dist/server/services/deploy/headless-session-handler.js +274 -0
  48. package/dist/server/services/deploy/headless-session-handler.js.map +1 -0
  49. package/dist/server/services/pathUtils.d.ts.map +1 -1
  50. package/dist/server/services/pathUtils.js +33 -1
  51. package/dist/server/services/pathUtils.js.map +1 -1
  52. package/dist/server/services/plan/agent-loader.d.ts +10 -0
  53. package/dist/server/services/plan/agent-loader.d.ts.map +1 -0
  54. package/dist/server/services/plan/agent-loader.js +65 -0
  55. package/dist/server/services/plan/agent-loader.js.map +1 -0
  56. package/dist/server/services/plan/composer.d.ts.map +1 -1
  57. package/dist/server/services/plan/composer.js +5 -1
  58. package/dist/server/services/plan/composer.js.map +1 -1
  59. package/dist/server/services/plan/dependency-resolver.d.ts +1 -1
  60. package/dist/server/services/plan/dependency-resolver.js +2 -2
  61. package/dist/server/services/plan/dependency-resolver.js.map +1 -1
  62. package/dist/server/services/plan/executor.d.ts +7 -3
  63. package/dist/server/services/plan/executor.d.ts.map +1 -1
  64. package/dist/server/services/plan/executor.js +27 -14
  65. package/dist/server/services/plan/executor.js.map +1 -1
  66. package/dist/server/services/plan/front-matter.d.ts +5 -0
  67. package/dist/server/services/plan/front-matter.d.ts.map +1 -1
  68. package/dist/server/services/plan/front-matter.js +19 -0
  69. package/dist/server/services/plan/front-matter.js.map +1 -1
  70. package/dist/server/services/plan/issue-prompt-builder.d.ts +1 -1
  71. package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
  72. package/dist/server/services/plan/issue-prompt-builder.js +1 -1
  73. package/dist/server/services/plan/issue-retry.d.ts +25 -0
  74. package/dist/server/services/plan/issue-retry.d.ts.map +1 -0
  75. package/dist/server/services/plan/issue-retry.js +216 -0
  76. package/dist/server/services/plan/issue-retry.js.map +1 -0
  77. package/dist/server/services/plan/output-manager.d.ts +2 -2
  78. package/dist/server/services/plan/output-manager.js +2 -2
  79. package/dist/server/services/plan/parser-core.d.ts +1 -1
  80. package/dist/server/services/plan/parser-core.js +1 -1
  81. package/dist/server/services/plan/parser-core.js.map +1 -1
  82. package/dist/server/services/plan/parser-migration.d.ts +2 -2
  83. package/dist/server/services/plan/parser-migration.d.ts.map +1 -1
  84. package/dist/server/services/plan/parser-migration.js +5 -5
  85. package/dist/server/services/plan/parser-migration.js.map +1 -1
  86. package/dist/server/services/plan/parser.d.ts.map +1 -1
  87. package/dist/server/services/plan/parser.js +4 -7
  88. package/dist/server/services/plan/parser.js.map +1 -1
  89. package/dist/server/services/plan/prompt-builder.d.ts +1 -1
  90. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -1
  91. package/dist/server/services/plan/review-gate.d.ts +4 -0
  92. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  93. package/dist/server/services/plan/review-gate.js +90 -35
  94. package/dist/server/services/plan/review-gate.js.map +1 -1
  95. package/dist/server/services/plan/state-reconciler.d.ts.map +1 -1
  96. package/dist/server/services/plan/state-reconciler.js +21 -11
  97. package/dist/server/services/plan/state-reconciler.js.map +1 -1
  98. package/dist/server/services/plan/types.d.ts +2 -2
  99. package/dist/server/services/plan/types.d.ts.map +1 -1
  100. package/dist/server/services/plan/watcher.js +1 -1
  101. package/dist/server/services/sentry.d.ts.map +1 -1
  102. package/dist/server/services/sentry.js +8 -4
  103. package/dist/server/services/sentry.js.map +1 -1
  104. package/dist/server/services/websocket/deploy-handlers.d.ts +14 -0
  105. package/dist/server/services/websocket/deploy-handlers.d.ts.map +1 -0
  106. package/dist/server/services/websocket/deploy-handlers.js +409 -0
  107. package/dist/server/services/websocket/deploy-handlers.js.map +1 -0
  108. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  109. package/dist/server/services/websocket/handler.js +12 -0
  110. package/dist/server/services/websocket/handler.js.map +1 -1
  111. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts +11 -0
  112. package/dist/server/services/websocket/handlers/deploy-handlers.d.ts.map +1 -0
  113. package/dist/server/services/websocket/handlers/deploy-handlers.js +180 -0
  114. package/dist/server/services/websocket/handlers/deploy-handlers.js.map +1 -0
  115. package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
  116. package/dist/server/services/websocket/plan-board-handlers.js +54 -1
  117. package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
  118. package/dist/server/services/websocket/plan-helpers.d.ts +1 -1
  119. package/dist/server/services/websocket/plan-helpers.d.ts.map +1 -1
  120. package/dist/server/services/websocket/plan-helpers.js +3 -4
  121. package/dist/server/services/websocket/plan-helpers.js.map +1 -1
  122. package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
  123. package/dist/server/services/websocket/plan-issue-handlers.js +5 -1
  124. package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
  125. package/dist/server/services/websocket/plan-sprint-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/plan-sprint-handlers.js +3 -11
  127. package/dist/server/services/websocket/plan-sprint-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
  129. package/dist/server/services/websocket/settings-handlers.js +17 -21
  130. package/dist/server/services/websocket/settings-handlers.js.map +1 -1
  131. package/dist/server/services/websocket/types.d.ts +264 -2
  132. package/dist/server/services/websocket/types.d.ts.map +1 -1
  133. package/package.json +1 -1
  134. package/server/cli/headless/claude-invoker-process.ts +1 -1
  135. package/server/cli/headless/headless-logger.ts +1 -1
  136. package/server/cli/headless/mcp-config.ts +4 -1
  137. package/server/cli/headless/runner.ts +1 -0
  138. package/server/cli/headless/types.ts +4 -1
  139. package/server/index.ts +9 -1
  140. package/server/mcp/bouncer-integration.ts +19 -17
  141. package/server/mcp/security-analysis.ts +19 -0
  142. package/server/mcp/security-patterns.ts +53 -2
  143. package/server/services/deploy/ai-broker.ts +512 -0
  144. package/server/services/deploy/board-execution-handler.ts +847 -0
  145. package/server/services/deploy/credentials.ts +200 -0
  146. package/server/services/deploy/deploy-ai-service.ts +401 -0
  147. package/server/services/deploy/headless-session-handler.ts +415 -0
  148. package/server/services/pathUtils.ts +35 -1
  149. package/server/services/plan/agent-loader.ts +73 -0
  150. package/server/services/plan/agents/review-code.md +28 -0
  151. package/server/services/plan/agents/review-custom.md +27 -0
  152. package/server/services/plan/agents/review-quality.md +42 -0
  153. package/server/services/plan/composer.ts +5 -1
  154. package/server/services/plan/dependency-resolver.ts +2 -2
  155. package/server/services/plan/executor.ts +27 -15
  156. package/server/services/plan/front-matter.ts +23 -0
  157. package/server/services/plan/issue-prompt-builder.ts +2 -2
  158. package/server/services/plan/issue-retry.ts +297 -0
  159. package/server/services/plan/output-manager.ts +2 -2
  160. package/server/services/plan/parser-core.ts +2 -2
  161. package/server/services/plan/parser-migration.ts +5 -5
  162. package/server/services/plan/parser.ts +4 -5
  163. package/server/services/plan/prompt-builder.ts +1 -1
  164. package/server/services/plan/review-gate.ts +105 -34
  165. package/server/services/plan/state-reconciler.ts +21 -11
  166. package/server/services/plan/types.ts +3 -3
  167. package/server/services/plan/watcher.ts +1 -1
  168. package/server/services/sentry.ts +8 -4
  169. package/server/services/websocket/deploy-handlers.ts +544 -0
  170. package/server/services/websocket/handler.ts +11 -1
  171. package/server/services/websocket/handlers/deploy-handlers.ts +230 -0
  172. package/server/services/websocket/plan-board-handlers.ts +53 -1
  173. package/server/services/websocket/plan-helpers.ts +3 -4
  174. package/server/services/websocket/plan-issue-handlers.ts +6 -1
  175. package/server/services/websocket/plan-sprint-handlers.ts +3 -9
  176. package/server/services/websocket/settings-handlers.ts +18 -22
  177. package/server/services/websocket/types.ts +333 -2
@@ -20,11 +20,11 @@ import { EventEmitter } from 'node:events';
20
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { runWithFileLogger } from '../../cli/headless/headless-logger.js';
23
- import { HeadlessRunner } from '../../cli/headless/index.js';
24
23
  import { ConfigInstaller } from './config-installer.js';
25
24
  import { resolveReadyToWork } from './dependency-resolver.js';
26
- import { replaceFrontMatterField, setFrontMatterField } from './front-matter.js';
25
+ import { checkAllAcceptanceCriteria, replaceFrontMatterField, setFrontMatterField } from './front-matter.js';
27
26
  import { buildIssuePrompt } from './issue-prompt-builder.js';
27
+ import { runIssueWithRetry } from './issue-retry.js';
28
28
  import { listExistingDocs, publishOutputs, resolveOutputPath } from './output-manager.js';
29
29
  import { parseBoardDirectory, parsePlanDirectory, resolvePmDir } from './parser.js';
30
30
  import { appendReviewFeedback, getReviewAttemptCount, MAX_REVIEW_ATTEMPTS, persistReviewResult, reviewIssue } from './review-gate.js';
@@ -62,13 +62,15 @@ export class PlanExecutor extends EventEmitter {
62
62
  private epicScope: string | null = null;
63
63
  /** Cached PM directory path — resolved once per start(). */
64
64
  private pmDir: string | null = null;
65
- /** Board directory path (e.g. /path/.pm/boards/BOARD-001). Used for outputs, reviews, progress. */
65
+ /** Board directory path (e.g. /path/.mstro/pm/boards/BOARD-001). Used for outputs, reviews, progress. */
66
66
  private boardDir: string | null = null;
67
67
  /** Board ID being executed (e.g. "BOARD-001") */
68
68
  private boardId: string | null = null;
69
69
  private configInstaller: ConfigInstaller;
70
70
  /** Flag to prevent start() from clearing scope set by startBoard/startEpic */
71
71
  private _scopeSetByCall = false;
72
+ /** Extra environment variables forwarded to HeadlessRunner child processes (e.g. API keys) */
73
+ private extraEnv?: Record<string, string>;
72
74
  private metrics: ExecutionMetrics = {
73
75
  issuesCompleted: 0,
74
76
  issuesAttempted: 0,
@@ -77,9 +79,10 @@ export class PlanExecutor extends EventEmitter {
77
79
  currentWaveIds: [],
78
80
  };
79
81
 
80
- constructor(workingDir: string) {
82
+ constructor(workingDir: string, options?: { extraEnv?: Record<string, string> }) {
81
83
  super();
82
84
  this.workingDir = workingDir;
85
+ this.extraEnv = options?.extraEnv;
83
86
  this.configInstaller = new ConfigInstaller(workingDir);
84
87
  }
85
88
 
@@ -223,7 +226,7 @@ export class PlanExecutor extends EventEmitter {
223
226
  return completedCount;
224
227
  }
225
228
 
226
- /** Run a single issue via its own headless Claude Code instance. */
229
+ /** Run a single issue via its own headless Claude Code instance with retry logic. */
227
230
  private async runSingleIssue(
228
231
  issue: Issue,
229
232
  pmDir: string | null,
@@ -240,21 +243,19 @@ export class PlanExecutor extends EventEmitter {
240
243
  outputPath,
241
244
  });
242
245
 
243
- const runner = new HeadlessRunner({
246
+ const boardLogDir = this.boardDir ? join(this.boardDir, 'logs') : undefined;
247
+ const result = await runWithFileLogger(`pm-issue-${issue.id}`, () => runIssueWithRetry({
244
248
  workingDir: this.workingDir,
245
- directPrompt: prompt,
249
+ prompt,
246
250
  stallWarningMs: ISSUE_STALL_WARNING_MS,
247
251
  stallKillMs: ISSUE_STALL_KILL_MS,
248
252
  stallHardCapMs: ISSUE_STALL_HARD_CAP_MS,
249
253
  stallMaxExtensions: ISSUE_STALL_MAX_EXTENSIONS,
250
- verbose: process.env.MSTRO_VERBOSE === '1',
251
254
  outputCallback: (text: string) => {
252
255
  this.emit('output', { issueId: issue.id, text });
253
256
  },
254
- });
255
-
256
- const boardLogDir = this.boardDir ? join(this.boardDir, 'logs') : undefined;
257
- const result = await runWithFileLogger(`pm-issue-${issue.id}`, () => runner.run(), boardLogDir);
257
+ extraEnv: this.extraEnv,
258
+ }), boardLogDir);
258
259
 
259
260
  if (!result.completed || result.error) {
260
261
  this.emit('output', { issueId: waveLabel, text: `Issue ${issue.id}: ${result.error || 'did not complete'}` });
@@ -317,9 +318,10 @@ export class PlanExecutor extends EventEmitter {
317
318
  const statusMatch = content.match(/^status:\s*(\S+)/m);
318
319
  const currentStatus = statusMatch?.[1] ?? 'unknown';
319
320
 
320
- if (currentStatus === 'done') {
321
+ if (currentStatus === 'in_review' || currentStatus === 'done') {
321
322
  if (issue.reviewGate === 'none') {
322
- // Skip review gate — accept agent's done status directly
323
+ // Skip review gate — mark done directly
324
+ this.updateIssueFrontMatter(issue.path, 'done');
323
325
  this.metrics.issuesCompleted++;
324
326
  this.emit('issueCompleted', issue);
325
327
  completed++;
@@ -364,6 +366,8 @@ export class PlanExecutor extends EventEmitter {
364
366
  onOutput: (text) => this.emit('output', { issueId: issue.id, text }),
365
367
  logDir: this.boardDir ? join(this.boardDir, 'logs') : undefined,
366
368
  reviewCriteria: this.getBoardReviewCriteria(),
369
+ boardDir: this.boardDir,
370
+ extraEnv: this.extraEnv,
367
371
  });
368
372
  persistReviewResult(reviewDir, issue, result);
369
373
 
@@ -545,7 +549,15 @@ export class PlanExecutor extends EventEmitter {
545
549
  const pmDir = this.pmDir;
546
550
  if (!pmDir) return;
547
551
  try {
548
- setFrontMatterField(join(pmDir, issuePath), 'status', newStatus);
552
+ const fullPath = join(pmDir, issuePath);
553
+ setFrontMatterField(fullPath, 'status', newStatus);
554
+
555
+ // Check off all acceptance criteria when marking done
556
+ if (newStatus === 'done') {
557
+ const content = readFileSync(fullPath, 'utf-8');
558
+ const updated = checkAllAcceptanceCriteria(content);
559
+ if (updated !== content) writeFileSync(fullPath, updated, 'utf-8');
560
+ }
549
561
  } catch { /* file may have been moved */ }
550
562
  }
551
563
 
@@ -46,3 +46,26 @@ export function setFrontMatterField(filePath: string, field: string, value: stri
46
46
  const updated = replaceFrontMatterField(content, field, value);
47
47
  writeFileSync(filePath, updated, 'utf-8');
48
48
  }
49
+
50
+ /**
51
+ * Check off all unchecked acceptance criteria checkboxes in a markdown string.
52
+ * Only modifies checkboxes within the "## Acceptance Criteria" section.
53
+ */
54
+ export function checkAllAcceptanceCriteria(content: string): string {
55
+ const sectionStart = content.indexOf('## Acceptance Criteria');
56
+ if (sectionStart === -1) return content;
57
+
58
+ const afterHeader = content.indexOf('\n', sectionStart);
59
+ if (afterHeader === -1) return content;
60
+
61
+ const nextSection = content.slice(afterHeader).search(/\n## /);
62
+ const sectionEnd = nextSection !== -1 ? afterHeader + nextSection : content.length;
63
+
64
+ const before = content.slice(0, afterHeader);
65
+ const section = content.slice(afterHeader, sectionEnd);
66
+ const after = content.slice(sectionEnd);
67
+
68
+ const updatedSection = section.replace(/^([-*]\s+)\[ \]/gm, '$1[x]');
69
+
70
+ return before + updatedSection + after;
71
+ }
@@ -15,7 +15,7 @@ export interface IssuePromptOptions {
15
15
  issue: Issue;
16
16
  workingDir: string;
17
17
  pmDir: string | null;
18
- /** Board directory path (e.g. /path/.pm/boards/BOARD-001). */
18
+ /** Board directory path (e.g. /path/.mstro/pm/boards/BOARD-001). */
19
19
  boardDir: string | null;
20
20
  existingDocs: string[];
21
21
  outputPath: string;
@@ -71,7 +71,7 @@ ${files}${predecessorSection}
71
71
  1. Read the full issue spec at ${pmDir ? join(pmDir, issue.path) : issue.path}
72
72
  2. Execute all acceptance criteria listed above
73
73
  3. Write your output and results to **${outputPath}** — this is the handoff artifact for downstream issues
74
- 4. After writing output, update the issue front matter: change \`status: in_progress\` to \`status: done\`
74
+ 4. After writing output, update the issue front matter: change \`status: in_progress\` to \`status: in_review\`
75
75
 
76
76
  ## Rules
77
77
 
@@ -0,0 +1,297 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Issue Retry — Retry loop for PM issue execution.
6
+ *
7
+ * Brings the same resilience as Chat view (improvisation-retry.ts) to PM agents:
8
+ * - Tool timeout checkpoint recovery (preserves completed tools, skips hung tool)
9
+ * - Signal crash recovery (preserves accumulated results across retries)
10
+ * - Premature completion handling (max_tokens / end_turn → resume with "continue")
11
+ *
12
+ * Unlike Chat's retry system, PM agents don't maintain session continuity across
13
+ * prompts — each issue is independent — so we skip inter-movement recovery and
14
+ * simplify the resume strategy.
15
+ */
16
+
17
+ import { hlog } from '../../cli/headless/headless-logger.js';
18
+ import { HeadlessRunner } from '../../cli/headless/index.js';
19
+ import { assessPrematureCompletion } from '../../cli/headless/stall-assessor.js';
20
+ import type { ExecutionCheckpoint, SessionResult } from '../../cli/headless/types.js';
21
+ import {
22
+ buildResumeRetryPrompt,
23
+ buildRetryPrompt,
24
+ buildSignalCrashRecoveryPrompt,
25
+ } from '../../cli/prompt-builders.js';
26
+
27
+ /** Max retries per issue execution (tool timeout, signal crash, premature completion combined) */
28
+ const MAX_ISSUE_RETRIES = 3;
29
+
30
+ /** Max accumulated tool results to carry across retries */
31
+ const MAX_ACCUMULATED_RESULTS = 50;
32
+
33
+ /** Lightweight tool record for accumulation across retries */
34
+ interface ToolRecord {
35
+ toolName: string;
36
+ toolId: string;
37
+ toolInput: Record<string, unknown>;
38
+ result?: string;
39
+ isError?: boolean;
40
+ duration?: number;
41
+ }
42
+
43
+ interface IssueRetryState {
44
+ currentPrompt: string;
45
+ retryNumber: number;
46
+ checkpoint: ExecutionCheckpoint | null;
47
+ accumulatedToolResults: ToolRecord[];
48
+ timedOutTools: Array<{ toolName: string; input: Record<string, unknown>; timeoutMs: number }>;
49
+ /** Session ID from a prior run — enables --resume for premature completion */
50
+ lastSessionId: string | undefined;
51
+ bestResult: SessionResult | null;
52
+ }
53
+
54
+ export interface IssueRunnerConfig {
55
+ workingDir: string;
56
+ /** Original enriched prompt for this issue */
57
+ prompt: string;
58
+ /** Stall detection timeouts (ms) */
59
+ stallWarningMs: number;
60
+ stallKillMs: number;
61
+ stallHardCapMs: number;
62
+ stallMaxExtensions: number;
63
+ /** Callback for streaming output to executor event bus */
64
+ outputCallback?: (text: string) => void;
65
+ /** Extra environment variables for spawned Claude processes (e.g. API keys) */
66
+ extraEnv?: Record<string, string>;
67
+ }
68
+
69
+ /**
70
+ * Execute a PM issue with retry logic.
71
+ *
72
+ * This wraps HeadlessRunner.run() with the same retry strategies as Chat view:
73
+ * 1. Tool timeout → checkpoint recovery with accumulated results
74
+ * 2. Signal crash → fresh start with preserved tool results
75
+ * 3. Premature completion → resume session with "continue"
76
+ */
77
+ export async function runIssueWithRetry(config: IssueRunnerConfig): Promise<SessionResult> {
78
+ const state: IssueRetryState = {
79
+ currentPrompt: config.prompt,
80
+ retryNumber: 0,
81
+ checkpoint: null,
82
+ accumulatedToolResults: [],
83
+ timedOutTools: [],
84
+ lastSessionId: undefined,
85
+ bestResult: null,
86
+ };
87
+
88
+ let result: SessionResult | undefined;
89
+
90
+ while (state.retryNumber <= MAX_ISSUE_RETRIES) {
91
+ // Clear checkpoint from prior iteration
92
+ state.checkpoint = null;
93
+
94
+ // Determine resume strategy
95
+ const useResume = !!state.lastSessionId;
96
+ const resumeSessionId = state.lastSessionId;
97
+ state.lastSessionId = undefined;
98
+
99
+ const runner = new HeadlessRunner({
100
+ workingDir: config.workingDir,
101
+ directPrompt: state.currentPrompt,
102
+ stallWarningMs: config.stallWarningMs,
103
+ stallKillMs: config.stallKillMs,
104
+ stallHardCapMs: config.stallHardCapMs,
105
+ stallMaxExtensions: config.stallMaxExtensions,
106
+ verbose: true,
107
+ continueSession: useResume,
108
+ claudeSessionId: resumeSessionId,
109
+ outputCallback: config.outputCallback,
110
+ onToolTimeout: (cp: ExecutionCheckpoint) => {
111
+ state.checkpoint = cp;
112
+ },
113
+ extraEnv: config.extraEnv,
114
+ });
115
+
116
+ result = await runner.run();
117
+
118
+ // Track best result for fallback selection
119
+ if (!state.bestResult || scoreResult(result) > scoreResult(state.bestResult)) {
120
+ state.bestResult = result;
121
+ }
122
+
123
+ // Evaluate retry strategies in priority order
124
+ if (tryToolTimeoutRetry(state, result, config)) continue;
125
+ if (trySignalCrashRetry(state, result, config)) continue;
126
+ if (await tryPrematureCompletionRetry(state, result, config)) continue;
127
+
128
+ // No retry needed — break out
129
+ break;
130
+ }
131
+
132
+ return result ?? state.bestResult ?? {
133
+ completed: false,
134
+ needsHandoff: false,
135
+ totalTokens: 0,
136
+ sessionId: '',
137
+ error: 'No result produced after retries',
138
+ };
139
+ }
140
+
141
+ // ========== Retry Strategies ==========
142
+
143
+ /**
144
+ * Strategy 1: Tool timeout checkpoint recovery.
145
+ * When a tool times out, we have a checkpoint with all completed tools.
146
+ * Build a new prompt injecting those results and skip the hung resource.
147
+ */
148
+ function tryToolTimeoutRetry(
149
+ state: IssueRetryState,
150
+ _result: SessionResult,
151
+ config: IssueRunnerConfig,
152
+ ): boolean {
153
+ if (!state.checkpoint || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
154
+
155
+ const cp = state.checkpoint;
156
+ state.retryNumber++;
157
+
158
+ state.timedOutTools.push({
159
+ toolName: cp.hungTool.toolName,
160
+ input: cp.hungTool.input ?? {},
161
+ timeoutMs: cp.hungTool.timeoutMs,
162
+ });
163
+
164
+ const canResume = cp.inProgressTools.length === 0 && !!cp.claudeSessionId;
165
+
166
+ hlog(`[PM-RETRY] Tool timeout: ${cp.hungTool.toolName} after ${Math.round(cp.hungTool.timeoutMs / 1000)}s, ${cp.completedTools.length} tools completed, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES} (${canResume ? 'resume' : 'fresh'})`);
167
+
168
+ if (canResume) {
169
+ state.lastSessionId = cp.claudeSessionId;
170
+ state.currentPrompt = buildResumeRetryPrompt(cp, state.timedOutTools);
171
+ } else {
172
+ state.currentPrompt = buildRetryPrompt(cp, config.prompt, state.timedOutTools);
173
+ }
174
+
175
+ config.outputCallback?.(`\n[PM-RETRY] Auto-retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}: ${canResume ? 'Resuming session' : 'Continuing'} with ${cp.completedTools.length} results, skipping failed ${cp.hungTool.toolName}.\n`);
176
+
177
+ return true;
178
+ }
179
+
180
+ /**
181
+ * Strategy 2: Signal crash recovery.
182
+ * Process was killed by signal (SIGTERM/SIGKILL from stall watchdog or OS).
183
+ * Accumulate completed tools and retry with preserved context.
184
+ */
185
+ function trySignalCrashRetry(
186
+ state: IssueRetryState,
187
+ result: SessionResult,
188
+ config: IssueRunnerConfig,
189
+ ): boolean {
190
+ const isSignalCrash = !!result.signalName;
191
+ const exitCodeSignal = !result.completed && !result.signalName && result.error?.match(/exited with code (1[2-9]\d|[2-9]\d{2})/);
192
+ if ((!isSignalCrash && !exitCodeSignal) || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
193
+ // Don't double-handle if a checkpoint was already captured (tool timeout takes priority)
194
+ if (state.checkpoint) return false;
195
+
196
+ accumulateToolResults(result, state);
197
+ state.retryNumber++;
198
+
199
+ const signalInfo = result.signalName || 'unknown signal';
200
+ const useResume = !!result.claudeSessionId && state.retryNumber === 1;
201
+
202
+ hlog(`[PM-RETRY] Signal crash: ${signalInfo}, ${state.accumulatedToolResults.length} tools preserved, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES} (${useResume ? 'resume' : 'fresh'})`);
203
+
204
+ if (useResume) {
205
+ state.lastSessionId = result.claudeSessionId;
206
+ state.currentPrompt = buildSignalCrashRecoveryPrompt(config.prompt, true);
207
+ } else {
208
+ state.currentPrompt = buildSignalCrashRecoveryPrompt(
209
+ config.prompt,
210
+ false,
211
+ state.accumulatedToolResults,
212
+ );
213
+ }
214
+
215
+ config.outputCallback?.(`\n[PM-RETRY] Signal recovery ${state.retryNumber}/${MAX_ISSUE_RETRIES}: ${useResume ? 'Resuming' : 'Restarting'} with ${state.accumulatedToolResults.length} preserved results.\n`);
216
+
217
+ return true;
218
+ }
219
+
220
+ /** Check if an end_turn result is actually incomplete using Haiku assessment. */
221
+ async function isEndTurnIncomplete(result: SessionResult): Promise<boolean> {
222
+ if (!result.assistantResponse) return false;
223
+ const claudeCmd = process.env.CLAUDE_COMMAND || 'claude';
224
+ try {
225
+ const verdict = await assessPrematureCompletion({
226
+ responseTail: result.assistantResponse.slice(-800),
227
+ successfulToolCalls: result.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0,
228
+ hasThinking: !!result.thinkingOutput,
229
+ responseLength: result.assistantResponse.length,
230
+ }, claudeCmd, true);
231
+
232
+ hlog(`[PM-RETRY] Premature completion check: ${verdict.isIncomplete ? 'INCOMPLETE' : 'COMPLETE'} — ${verdict.reason}`);
233
+ return verdict.isIncomplete;
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Strategy 3: Premature completion.
241
+ * Claude hit max_tokens or ended early without finishing work.
242
+ * Resume the session with "continue".
243
+ */
244
+ async function tryPrematureCompletionRetry(
245
+ state: IssueRetryState,
246
+ result: SessionResult,
247
+ config: IssueRunnerConfig,
248
+ ): Promise<boolean> {
249
+ if (!result.completed || result.signalName || state.retryNumber >= MAX_ISSUE_RETRIES) return false;
250
+ if (state.checkpoint) return false;
251
+ if (!result.claudeSessionId || !result.stopReason) return false;
252
+
253
+ const isMaxTokens = result.stopReason === 'max_tokens';
254
+ const isEndTurn = result.stopReason === 'end_turn';
255
+ if (!isMaxTokens && !isEndTurn) return false;
256
+
257
+ // max_tokens always continues; end_turn requires AI assessment
258
+ if (isEndTurn && !(await isEndTurnIncomplete(result))) return false;
259
+
260
+ state.retryNumber++;
261
+ state.lastSessionId = result.claudeSessionId;
262
+ state.currentPrompt = 'continue';
263
+
264
+ const reason = isMaxTokens ? 'Output limit reached' : 'Task appears unfinished';
265
+ hlog(`[PM-RETRY] Premature completion: ${reason}, resuming session, retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}`);
266
+ config.outputCallback?.(`\n[PM-RETRY] ${reason} — resuming session (retry ${state.retryNumber}/${MAX_ISSUE_RETRIES}).\n`);
267
+
268
+ return true;
269
+ }
270
+
271
+ // ========== Helpers ==========
272
+
273
+ function accumulateToolResults(result: SessionResult, state: IssueRetryState): void {
274
+ if (!result.toolUseHistory) return;
275
+ for (const t of result.toolUseHistory) {
276
+ if (t.result !== undefined) {
277
+ state.accumulatedToolResults.push({
278
+ toolName: t.toolName,
279
+ toolId: t.toolId,
280
+ toolInput: t.toolInput,
281
+ result: t.result,
282
+ isError: t.isError,
283
+ duration: t.duration,
284
+ });
285
+ }
286
+ }
287
+ if (state.accumulatedToolResults.length > MAX_ACCUMULATED_RESULTS) {
288
+ state.accumulatedToolResults = state.accumulatedToolResults.slice(-MAX_ACCUMULATED_RESULTS);
289
+ }
290
+ }
291
+
292
+ function scoreResult(r: SessionResult): number {
293
+ const toolCount = r.toolUseHistory?.filter(t => t.result !== undefined && !t.isError).length ?? 0;
294
+ const responseLen = Math.min((r.assistantResponse?.length ?? 0) / 50, 100);
295
+ const hasThinking = r.thinkingOutput ? 20 : 0;
296
+ return toolCount * 10 + responseLen + hasThinking;
297
+ }
@@ -23,7 +23,7 @@ export function slugify(text: string): string {
23
23
 
24
24
  /**
25
25
  * Resolve the canonical output path for an issue.
26
- * Uses sprint sandbox when available, otherwise global .pm/out/.
26
+ * Uses sprint sandbox when available, otherwise global .mstro/pm/out/.
27
27
  */
28
28
  export function resolveOutputPath(issue: Issue, workingDir: string, sprintSandboxDir: string | null): string {
29
29
  if (sprintSandboxDir) {
@@ -61,7 +61,7 @@ export interface PublishOutputsCallbacks {
61
61
  }
62
62
 
63
63
  /**
64
- * Copy confirmed-done outputs from .pm/out/ to user-specified output_file paths.
64
+ * Copy confirmed-done outputs from .mstro/pm/out/ to user-specified output_file paths.
65
65
  * Only copies for issues that completed successfully and have output_file set.
66
66
  */
67
67
  export function publishOutputs(
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  /**
5
- * YAML front-matter parsing and entity parsers for PPS (.pm/) files.
5
+ * YAML front-matter parsing and entity parsers for PPS (.mstro/pm/) files.
6
6
  */
7
7
 
8
8
  import type {
@@ -264,7 +264,7 @@ export function parseIssue(content: string, filePath: string): Issue {
264
264
  id: String(fm.id || ''),
265
265
  title: String(fm.title || ''),
266
266
  type: (fm.type as Issue['type']) || 'issue',
267
- status: String(fm.status || 'backlog'),
267
+ status: String(fm.status || 'todo'),
268
268
  priority: String(fm.priority || 'P2'),
269
269
  estimate: fm.estimate != null ? fm.estimate as number | string : null,
270
270
  labels: toStringArray(fm.labels),
@@ -2,14 +2,14 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  /**
5
- * Legacy → Board-centric migration for .pm/ directories.
5
+ * Legacy → Board-centric migration for .mstro/pm/ directories.
6
6
  */
7
7
 
8
8
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { parseFrontMatter } from './parser-core.js';
11
11
 
12
- /** Check whether a .pm/ directory uses the legacy flat format (has backlog/ at root, no boards/). */
12
+ /** Check whether a .mstro/pm/ directory uses the legacy flat format (has backlog/ at root, no boards/). */
13
13
  export function isLegacyFormat(pmDir: string): boolean {
14
14
  return existsSync(join(pmDir, 'backlog')) && !existsSync(join(pmDir, 'boards'));
15
15
  }
@@ -73,10 +73,10 @@ function cleanupMigratedIssues(boardBacklogDir: string): boolean {
73
73
  const content = readFileIfExists(join(boardBacklogDir, f));
74
74
  if (!content) continue;
75
75
  const fm = parseFrontMatter(content).frontMatter;
76
- const status = String(fm.status || 'backlog');
76
+ const status = String(fm.status || 'todo');
77
77
  if (status === 'done' || status === 'closed' || status === 'cancelled') {
78
78
  rmSync(join(boardBacklogDir, f));
79
- } else if (status !== 'backlog') {
79
+ } else if (status !== 'todo') {
80
80
  hasActive = true;
81
81
  }
82
82
  }
@@ -104,7 +104,7 @@ function writeBoardMetadata(pmDir: string, boardDir: string, boardId: string, sp
104
104
  }, null, 2));
105
105
  }
106
106
 
107
- /** Migrate a legacy flat .pm/ directory to the board-centric format. */
107
+ /** Migrate a legacy flat .mstro/pm/ directory to the board-centric format. */
108
108
  export function migrateToBoards(pmDir: string): void {
109
109
  const boardId = 'BOARD-001';
110
110
  const boardDir = join(pmDir, 'boards', boardId);
@@ -2,7 +2,7 @@
2
2
  // Licensed under the MIT License. See LICENSE file for details.
3
3
 
4
4
  /**
5
- * PPS Parser — Public API for reading .pm/ (or legacy .plan/) directories.
5
+ * PPS Parser — Public API for reading .mstro/pm/ directories.
6
6
  *
7
7
  * Entity parsing lives in parser-core.ts; migration in parser-migration.ts.
8
8
  */
@@ -37,10 +37,6 @@ export function isBoardCentricFormat(pmDir: string): boolean {
37
37
  export function resolvePmDir(workingDir: string): string | null {
38
38
  const mstroPmDir = join(workingDir, '.mstro', 'pm');
39
39
  if (existsSync(mstroPmDir)) return mstroPmDir;
40
- const legacyPmDir = join(workingDir, '.pm');
41
- if (existsSync(legacyPmDir)) return legacyPmDir;
42
- const legacyPlanDir = join(workingDir, '.plan');
43
- if (existsSync(legacyPlanDir)) return legacyPlanDir;
44
40
  return null;
45
41
  }
46
42
 
@@ -125,6 +121,9 @@ export function parseBoardDirectory(pmDir: string, boardId: string): BoardFullSt
125
121
  issue.blockedBy = issue.blockedBy.map(bp => bp.startsWith('boards/') ? bp : `${boardPrefix}${bp}`);
126
122
  issue.blocks = issue.blocks.map(bp => bp.startsWith('boards/') ? bp : `${boardPrefix}${bp}`);
127
123
  if (issue.epic && !issue.epic.startsWith('boards/')) issue.epic = `${boardPrefix}${issue.epic}`;
124
+ if (issue.children.length > 0) {
125
+ issue.children = issue.children.map(cp => cp.startsWith('boards/') ? cp : `${boardPrefix}${cp}`);
126
+ }
128
127
  return issue;
129
128
  });
130
129
 
@@ -15,7 +15,7 @@ export interface CoordinatorPromptOptions {
15
15
  issues: Issue[];
16
16
  workingDir: string;
17
17
  pmDir: string | null;
18
- /** Board directory path when executing a board (e.g. /path/.pm/boards/BOARD-001). */
18
+ /** Board directory path when executing a board (e.g. /path/.mstro/pm/boards/BOARD-001). */
19
19
  boardDir: string | null;
20
20
  existingDocs: string[];
21
21
  resolveOutputPath: (issue: Issue) => string;