mstro-app 0.4.51 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +10 -5
  2. package/bin/mstro.js +1 -1
  3. package/dist/server/cli/headless/claude-invoker-stall.d.ts.map +1 -1
  4. package/dist/server/cli/headless/claude-invoker-stall.js +7 -2
  5. package/dist/server/cli/headless/claude-invoker-stall.js.map +1 -1
  6. package/dist/server/cli/headless/claude-invoker.js +1 -1
  7. package/dist/server/cli/headless/claude-invoker.js.map +1 -1
  8. package/dist/server/cli/headless/runner.d.ts.map +1 -1
  9. package/dist/server/cli/headless/runner.js +63 -67
  10. package/dist/server/cli/headless/runner.js.map +1 -1
  11. package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
  12. package/dist/server/cli/headless/stall-assessor.js +9 -4
  13. package/dist/server/cli/headless/stall-assessor.js.map +1 -1
  14. package/dist/server/cli/improvisation-history-store.d.ts +16 -0
  15. package/dist/server/cli/improvisation-history-store.d.ts.map +1 -0
  16. package/dist/server/cli/improvisation-history-store.js +52 -0
  17. package/dist/server/cli/improvisation-history-store.js.map +1 -0
  18. package/dist/server/cli/improvisation-movements.d.ts +31 -0
  19. package/dist/server/cli/improvisation-movements.d.ts.map +1 -0
  20. package/dist/server/cli/improvisation-movements.js +93 -0
  21. package/dist/server/cli/improvisation-movements.js.map +1 -0
  22. package/dist/server/cli/improvisation-output-queue.d.ts +13 -0
  23. package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -0
  24. package/dist/server/cli/improvisation-output-queue.js +40 -0
  25. package/dist/server/cli/improvisation-output-queue.js.map +1 -0
  26. package/dist/server/cli/improvisation-retry.d.ts +21 -51
  27. package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
  28. package/dist/server/cli/improvisation-retry.js +18 -433
  29. package/dist/server/cli/improvisation-retry.js.map +1 -1
  30. package/dist/server/cli/improvisation-session-manager.d.ts +10 -8
  31. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
  32. package/dist/server/cli/improvisation-session-manager.js +53 -148
  33. package/dist/server/cli/improvisation-session-manager.js.map +1 -1
  34. package/dist/server/cli/retry/retry-best-result.d.ts +4 -0
  35. package/dist/server/cli/retry/retry-best-result.d.ts.map +1 -0
  36. package/dist/server/cli/retry/retry-best-result.js +61 -0
  37. package/dist/server/cli/retry/retry-best-result.js.map +1 -0
  38. package/dist/server/cli/retry/retry-context-loss.d.ts +6 -0
  39. package/dist/server/cli/retry/retry-context-loss.d.ts.map +1 -0
  40. package/dist/server/cli/retry/retry-context-loss.js +68 -0
  41. package/dist/server/cli/retry/retry-context-loss.js.map +1 -0
  42. package/dist/server/cli/retry/retry-premature-completion.d.ts +5 -0
  43. package/dist/server/cli/retry/retry-premature-completion.d.ts.map +1 -0
  44. package/dist/server/cli/retry/retry-premature-completion.js +81 -0
  45. package/dist/server/cli/retry/retry-premature-completion.js.map +1 -0
  46. package/dist/server/cli/retry/retry-recovery-strategies.d.ts +13 -0
  47. package/dist/server/cli/retry/retry-recovery-strategies.d.ts.map +1 -0
  48. package/dist/server/cli/retry/retry-recovery-strategies.js +166 -0
  49. package/dist/server/cli/retry/retry-recovery-strategies.js.map +1 -0
  50. package/dist/server/cli/retry/retry-resume-strategy.d.ts +12 -0
  51. package/dist/server/cli/retry/retry-resume-strategy.d.ts.map +1 -0
  52. package/dist/server/cli/retry/retry-resume-strategy.js +22 -0
  53. package/dist/server/cli/retry/retry-resume-strategy.js.map +1 -0
  54. package/dist/server/cli/retry/retry-runner-factory.d.ts +11 -0
  55. package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -0
  56. package/dist/server/cli/retry/retry-runner-factory.js +60 -0
  57. package/dist/server/cli/retry/retry-runner-factory.js.map +1 -0
  58. package/dist/server/cli/retry/retry-tool-results.d.ts +9 -0
  59. package/dist/server/cli/retry/retry-tool-results.d.ts.map +1 -0
  60. package/dist/server/cli/retry/retry-tool-results.js +24 -0
  61. package/dist/server/cli/retry/retry-tool-results.js.map +1 -0
  62. package/dist/server/cli/retry/retry-types.d.ts +30 -0
  63. package/dist/server/cli/retry/retry-types.d.ts.map +1 -0
  64. package/dist/server/cli/retry/retry-types.js +4 -0
  65. package/dist/server/cli/retry/retry-types.js.map +1 -0
  66. package/dist/server/index.js +21 -109
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/server/server-setup.d.ts +16 -1
  69. package/dist/server/server-setup.d.ts.map +1 -1
  70. package/dist/server/server-setup.js +107 -0
  71. package/dist/server/server-setup.js.map +1 -1
  72. package/dist/server/services/plan/board-config.d.ts +21 -0
  73. package/dist/server/services/plan/board-config.d.ts.map +1 -0
  74. package/dist/server/services/plan/board-config.js +112 -0
  75. package/dist/server/services/plan/board-config.js.map +1 -0
  76. package/dist/server/services/plan/composer.d.ts +1 -1
  77. package/dist/server/services/plan/composer.d.ts.map +1 -1
  78. package/dist/server/services/plan/composer.js +7 -5
  79. package/dist/server/services/plan/composer.js.map +1 -1
  80. package/dist/server/services/plan/executor.d.ts +48 -48
  81. package/dist/server/services/plan/executor.d.ts.map +1 -1
  82. package/dist/server/services/plan/executor.js +157 -455
  83. package/dist/server/services/plan/executor.js.map +1 -1
  84. package/dist/server/services/plan/issue-loader.d.ts +16 -0
  85. package/dist/server/services/plan/issue-loader.d.ts.map +1 -0
  86. package/dist/server/services/plan/issue-loader.js +46 -0
  87. package/dist/server/services/plan/issue-loader.js.map +1 -0
  88. package/dist/server/services/plan/issue-writer.d.ts +34 -0
  89. package/dist/server/services/plan/issue-writer.d.ts.map +1 -0
  90. package/dist/server/services/plan/issue-writer.js +110 -0
  91. package/dist/server/services/plan/issue-writer.js.map +1 -0
  92. package/dist/server/services/plan/output-manager.d.ts.map +1 -1
  93. package/dist/server/services/plan/output-manager.js +2 -1
  94. package/dist/server/services/plan/output-manager.js.map +1 -1
  95. package/dist/server/services/plan/progress-log.d.ts +11 -0
  96. package/dist/server/services/plan/progress-log.d.ts.map +1 -0
  97. package/dist/server/services/plan/progress-log.js +81 -0
  98. package/dist/server/services/plan/progress-log.js.map +1 -0
  99. package/dist/server/services/plan/prompt-builder.d.ts.map +1 -1
  100. package/dist/server/services/plan/prompt-builder.js +48 -31
  101. package/dist/server/services/plan/prompt-builder.js.map +1 -1
  102. package/dist/server/services/plan/readiness-planner.d.ts +15 -0
  103. package/dist/server/services/plan/readiness-planner.d.ts.map +1 -0
  104. package/dist/server/services/plan/readiness-planner.js +41 -0
  105. package/dist/server/services/plan/readiness-planner.js.map +1 -0
  106. package/dist/server/services/plan/review-gate.d.ts +31 -0
  107. package/dist/server/services/plan/review-gate.d.ts.map +1 -1
  108. package/dist/server/services/plan/review-gate.js +52 -2
  109. package/dist/server/services/plan/review-gate.js.map +1 -1
  110. package/dist/server/services/platform.d.ts +56 -0
  111. package/dist/server/services/platform.d.ts.map +1 -1
  112. package/dist/server/services/platform.js +154 -52
  113. package/dist/server/services/platform.js.map +1 -1
  114. package/dist/server/services/websocket/file-download-handler.d.ts +17 -0
  115. package/dist/server/services/websocket/file-download-handler.d.ts.map +1 -0
  116. package/dist/server/services/websocket/file-download-handler.js +165 -0
  117. package/dist/server/services/websocket/file-download-handler.js.map +1 -0
  118. package/dist/server/services/websocket/git-branch-handlers.d.ts +1 -1
  119. package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
  120. package/dist/server/services/websocket/git-branch-handlers.js +21 -1
  121. package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
  122. package/dist/server/services/websocket/git-handlers.js +1 -1
  123. package/dist/server/services/websocket/git-handlers.js.map +1 -1
  124. package/dist/server/services/websocket/git-worktree-handlers.d.ts +2 -0
  125. package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
  126. package/dist/server/services/websocket/git-worktree-handlers.js +30 -4
  127. package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
  128. package/dist/server/services/websocket/handler-context.d.ts +15 -0
  129. package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
  130. package/dist/server/services/websocket/handler.d.ts +7 -0
  131. package/dist/server/services/websocket/handler.d.ts.map +1 -1
  132. package/dist/server/services/websocket/handler.js +73 -11
  133. package/dist/server/services/websocket/handler.js.map +1 -1
  134. package/dist/server/services/websocket/msg-id-tracker.d.ts +21 -0
  135. package/dist/server/services/websocket/msg-id-tracker.d.ts.map +1 -0
  136. package/dist/server/services/websocket/msg-id-tracker.js +77 -0
  137. package/dist/server/services/websocket/msg-id-tracker.js.map +1 -0
  138. package/dist/server/services/websocket/quality-handlers.js +15 -3
  139. package/dist/server/services/websocket/quality-handlers.js.map +1 -1
  140. package/dist/server/services/websocket/quality-review-agent.js +2 -2
  141. package/dist/server/services/websocket/session-handlers.d.ts +48 -2
  142. package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
  143. package/dist/server/services/websocket/session-handlers.js +204 -65
  144. package/dist/server/services/websocket/session-handlers.js.map +1 -1
  145. package/dist/server/services/websocket/session-initialization.d.ts +2 -2
  146. package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
  147. package/dist/server/services/websocket/session-initialization.js +75 -17
  148. package/dist/server/services/websocket/session-initialization.js.map +1 -1
  149. package/dist/server/services/websocket/session-registry.d.ts +29 -1
  150. package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
  151. package/dist/server/services/websocket/session-registry.js +53 -4
  152. package/dist/server/services/websocket/session-registry.js.map +1 -1
  153. package/dist/server/services/websocket/tab-broadcast.d.ts +24 -0
  154. package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -0
  155. package/dist/server/services/websocket/tab-broadcast.js +13 -0
  156. package/dist/server/services/websocket/tab-broadcast.js.map +1 -0
  157. package/dist/server/services/websocket/tab-event-buffer.d.ts +103 -0
  158. package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -0
  159. package/dist/server/services/websocket/tab-event-buffer.js +107 -0
  160. package/dist/server/services/websocket/tab-event-buffer.js.map +1 -0
  161. package/dist/server/services/websocket/tab-event-replay.d.ts +20 -0
  162. package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -0
  163. package/dist/server/services/websocket/tab-event-replay.js +21 -0
  164. package/dist/server/services/websocket/tab-event-replay.js.map +1 -0
  165. package/dist/server/services/websocket/tab-handlers.d.ts +0 -1
  166. package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
  167. package/dist/server/services/websocket/tab-handlers.js +2 -9
  168. package/dist/server/services/websocket/tab-handlers.js.map +1 -1
  169. package/dist/server/services/websocket/types.d.ts +15 -6
  170. package/dist/server/services/websocket/types.d.ts.map +1 -1
  171. package/dist/server/services/websocket/types.js +6 -4
  172. package/dist/server/services/websocket/types.js.map +1 -1
  173. package/package.json +1 -1
  174. package/server/README.md +1 -1
  175. package/server/cli/headless/claude-invoker-stall.ts +7 -2
  176. package/server/cli/headless/claude-invoker.ts +1 -1
  177. package/server/cli/headless/runner.ts +67 -72
  178. package/server/cli/headless/stall-assessor.ts +9 -4
  179. package/server/cli/headless/types.ts +1 -1
  180. package/server/cli/improvisation-history-store.ts +62 -0
  181. package/server/cli/improvisation-movements.ts +120 -0
  182. package/server/cli/improvisation-output-queue.ts +42 -0
  183. package/server/cli/improvisation-retry.ts +25 -600
  184. package/server/cli/improvisation-session-manager.ts +74 -160
  185. package/server/cli/retry/retry-best-result.ts +70 -0
  186. package/server/cli/retry/retry-context-loss.ts +87 -0
  187. package/server/cli/retry/retry-premature-completion.ts +113 -0
  188. package/server/cli/retry/retry-recovery-strategies.ts +247 -0
  189. package/server/cli/retry/retry-resume-strategy.ts +33 -0
  190. package/server/cli/retry/retry-runner-factory.ts +70 -0
  191. package/server/cli/retry/retry-tool-results.ts +31 -0
  192. package/server/cli/retry/retry-types.ts +32 -0
  193. package/server/index.ts +37 -123
  194. package/server/server-setup.ts +126 -1
  195. package/server/services/plan/agents/assess-stall.md +11 -4
  196. package/server/services/plan/board-config.ts +122 -0
  197. package/server/services/plan/composer.ts +7 -5
  198. package/server/services/plan/executor.ts +214 -467
  199. package/server/services/plan/issue-loader.ts +64 -0
  200. package/server/services/plan/issue-writer.ts +137 -0
  201. package/server/services/plan/output-manager.ts +2 -1
  202. package/server/services/plan/progress-log.ts +92 -0
  203. package/server/services/plan/prompt-builder.ts +73 -35
  204. package/server/services/plan/readiness-planner.ts +50 -0
  205. package/server/services/plan/review-gate.ts +102 -2
  206. package/server/services/platform.ts +163 -58
  207. package/server/services/websocket/file-download-handler.ts +191 -0
  208. package/server/services/websocket/git-branch-handlers.ts +28 -1
  209. package/server/services/websocket/git-handlers.ts +1 -1
  210. package/server/services/websocket/git-worktree-handlers.ts +31 -4
  211. package/server/services/websocket/handler-context.ts +15 -0
  212. package/server/services/websocket/handler.ts +76 -12
  213. package/server/services/websocket/msg-id-tracker.ts +84 -0
  214. package/server/services/websocket/quality-handlers.ts +16 -3
  215. package/server/services/websocket/quality-review-agent.ts +2 -2
  216. package/server/services/websocket/session-handlers.ts +213 -68
  217. package/server/services/websocket/session-initialization.ts +83 -19
  218. package/server/services/websocket/session-registry.ts +61 -4
  219. package/server/services/websocket/tab-broadcast.ts +38 -0
  220. package/server/services/websocket/tab-event-buffer.ts +159 -0
  221. package/server/services/websocket/tab-event-replay.ts +42 -0
  222. package/server/services/websocket/tab-handlers.ts +2 -9
  223. package/server/services/websocket/types.ts +17 -4
@@ -0,0 +1,64 @@
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 loader: wraps parser.ts to load either board-scoped or
6
+ * project-level issues, emitting error/activation signals via callbacks
7
+ * so it stays decoupled from the EventEmitter in PlanExecutor.
8
+ */
9
+
10
+ import { activateBoard } from './board-config.js';
11
+ import type { WarnFn } from './issue-writer.js';
12
+ import { parseBoardDirectory, parsePlanDirectory } from './parser.js';
13
+ import type { Issue } from './types.js';
14
+
15
+ export interface IssueLoaderHandlers {
16
+ /** Invoked when the board/project is in an unloadable state (not found, paused, etc.). */
17
+ onError: (message: string) => void;
18
+ /** Invoked to forward warnings from `activateBoard` side-effects. */
19
+ warn: WarnFn;
20
+ }
21
+
22
+ /**
23
+ * Load issues from a specific board, auto-activating draft boards.
24
+ * Returns null on error (and invokes `handlers.onError`).
25
+ */
26
+ export async function loadBoardIssues(
27
+ pmDir: string,
28
+ boardId: string,
29
+ handlers: IssueLoaderHandlers,
30
+ ): Promise<Issue[] | null> {
31
+ const boardState = parseBoardDirectory(pmDir, boardId);
32
+ if (!boardState) {
33
+ handlers.onError(`Board not found: ${boardId}`);
34
+ return null;
35
+ }
36
+ if (boardState.state.paused) {
37
+ handlers.onError('Board is paused');
38
+ return null;
39
+ }
40
+ if (boardState.board.status === 'draft') {
41
+ await activateBoard(pmDir, boardId, handlers.warn);
42
+ } else if (boardState.board.status !== 'active') {
43
+ handlers.onError(`Board ${boardId} is not active (status: ${boardState.board.status})`);
44
+ return null;
45
+ }
46
+ return boardState.issues;
47
+ }
48
+
49
+ /** Load project-level issues (legacy or no boards). Returns null on error. */
50
+ export function loadProjectIssues(
51
+ workingDir: string,
52
+ handlers: Pick<IssueLoaderHandlers, 'onError'>,
53
+ ): Issue[] | null {
54
+ const fullState = parsePlanDirectory(workingDir);
55
+ if (!fullState) {
56
+ handlers.onError('No PM directory found');
57
+ return null;
58
+ }
59
+ if (fullState.state.paused) {
60
+ handlers.onError('Project is paused');
61
+ return null;
62
+ }
63
+ return fullState.issues;
64
+ }
@@ -0,0 +1,137 @@
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 file mutations: front-matter status updates, activity note appends,
6
+ * stale-issue recovery, and wave-revert logic used by the plan executor.
7
+ */
8
+
9
+ import { readFile, writeFile } from 'node:fs/promises';
10
+ import { isAbsolute, relative, resolve } from 'node:path';
11
+ import { checkAllAcceptanceCriteria, setFrontMatterFieldAsync } from './front-matter.js';
12
+ import type { Issue } from './types.js';
13
+
14
+ /** Emits a warning message — caller typically maps this to executor 'output' events. */
15
+ export type WarnFn = (message: string, issueId?: string) => void;
16
+
17
+ /** Matches the `status: <value>` front-matter line (YAML-style, first occurrence). */
18
+ const STATUS_LINE_PATTERN = /^status:\s*(\S+)/m;
19
+
20
+ /**
21
+ * Extract the `status:` front-matter value from an issue-file body. Returns
22
+ * `null` if the field is missing or malformed. Lives here alongside the other
23
+ * front-matter mutators so all modules parse the status identically.
24
+ */
25
+ export function extractIssueStatus(content: string): string | null {
26
+ return content.match(STATUS_LINE_PATTERN)?.[1] ?? null;
27
+ }
28
+
29
+ /**
30
+ * Resolve an issue's relative path against a base directory, ensuring it
31
+ * stays inside the base (guards against `..` and absolute-path escapes).
32
+ */
33
+ export function validateIssuePath(issuePath: string, baseDir: string): string {
34
+ const resolvedBase = resolve(baseDir);
35
+ const resolvedFull = resolve(resolvedBase, issuePath);
36
+ const rel = relative(resolvedBase, resolvedFull);
37
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
38
+ throw new Error(`Invalid issue path: path traversal detected in "${issuePath}"`);
39
+ }
40
+ return resolvedFull;
41
+ }
42
+
43
+ /**
44
+ * Update an issue's `status:` front-matter field, and — when transitioning
45
+ * to `done` — check off any remaining acceptance-criteria checkboxes.
46
+ */
47
+ export async function updateIssueFrontMatter(
48
+ pmDir: string,
49
+ issuePath: string,
50
+ newStatus: string,
51
+ warn: WarnFn,
52
+ ): Promise<void> {
53
+ try {
54
+ const fullPath = validateIssuePath(issuePath, pmDir);
55
+ await setFrontMatterFieldAsync(fullPath, 'status', newStatus);
56
+
57
+ if (newStatus === 'done') {
58
+ const content = await readFile(fullPath, 'utf-8');
59
+ const updated = checkAllAcceptanceCriteria(content);
60
+ if (updated !== content) await writeFile(fullPath, updated, 'utf-8');
61
+ }
62
+ } catch (err) {
63
+ warn(`Warning: failed to update issue front matter for ${issuePath}: ${errMsg(err)}`);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * After a wave fails, revert any issue still stuck in `in_progress` back to
69
+ * its pre-wave status. Silently skips issues already out of `in_progress`.
70
+ */
71
+ export async function revertIncompleteIssues(
72
+ pmDir: string,
73
+ issues: Issue[],
74
+ warn: WarnFn,
75
+ ): Promise<void> {
76
+ for (const issue of issues) {
77
+ const fullPath = validateIssuePath(issue.path, pmDir);
78
+ try {
79
+ const content = await readFile(fullPath, 'utf-8');
80
+ if (extractIssueStatus(content) === 'in_progress') {
81
+ await updateIssueFrontMatter(pmDir, issue.path, issue.status, warn);
82
+ }
83
+ } catch (err) {
84
+ warn(`Warning: failed to revert issue status: ${errMsg(err)}`, issue.id);
85
+ }
86
+ }
87
+ }
88
+
89
+ /** Append a cancellation note to the issue's `## Activity` section. */
90
+ export async function appendCancellationNote(
91
+ pmDir: string,
92
+ issue: Issue,
93
+ reason: string,
94
+ warn: WarnFn,
95
+ ): Promise<void> {
96
+ const fullPath = validateIssuePath(issue.path, pmDir);
97
+ try {
98
+ let content = await readFile(fullPath, 'utf-8');
99
+ const entry = `- Cancelled (${new Date().toISOString().split('T')[0]}): ${reason}`;
100
+ if (content.includes('## Activity')) {
101
+ content = content.replace(/## Activity/, `## Activity\n${entry}`);
102
+ } else {
103
+ content += `\n\n## Activity\n${entry}`;
104
+ }
105
+ await writeFile(fullPath, content, 'utf-8');
106
+ } catch (err) {
107
+ warn(`Warning: failed to append cancellation note: ${errMsg(err)}`, issue.id);
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Recover from a previous interrupted execution by reverting stale
113
+ * `in_progress` and `in_review` issues back to `todo`. Returns the list of
114
+ * recovered issue descriptors (e.g. "FEAT-012 (in_progress → todo)") so the
115
+ * caller can emit a single summary event.
116
+ */
117
+ export async function recoverStaleIssues(
118
+ pmDir: string,
119
+ issues: Issue[],
120
+ warn: WarnFn,
121
+ ): Promise<string[]> {
122
+ const staleStatuses = new Set(['in_progress', 'in_review']);
123
+ const recovered: string[] = [];
124
+
125
+ for (const issue of issues) {
126
+ if (issue.type === 'epic') continue;
127
+ if (staleStatuses.has(issue.status)) {
128
+ await updateIssueFrontMatter(pmDir, issue.path, 'todo', warn);
129
+ recovered.push(`${issue.id} (${issue.status} → todo)`);
130
+ }
131
+ }
132
+ return recovered;
133
+ }
134
+
135
+ function errMsg(err: unknown): string {
136
+ return err instanceof Error ? err.message : String(err);
137
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
11
11
  import { join, resolve } from 'node:path';
12
+ import { extractIssueStatus } from './issue-writer.js';
12
13
  import { defaultPmDir, resolvePmDir } from './parser.js';
13
14
  import type { Issue } from './types.js';
14
15
 
@@ -90,7 +91,7 @@ function publishSingleOutput(
90
91
  // Only publish for confirmed-done issues
91
92
  try {
92
93
  const content = readFileSync(join(pmDir, issue.path), 'utf-8');
93
- if (!content.match(/^status:\s*done$/m)) return;
94
+ if (extractIssueStatus(content) !== 'done') return;
94
95
  } catch { return; }
95
96
 
96
97
  const srcPath = resolveOutputPath(issue, workingDir, sprintSandboxDir);
@@ -0,0 +1,92 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Progress log writer: ensures output directories exist and appends
6
+ * per-wave markdown entries to the board or PM-dir progress.md.
7
+ */
8
+
9
+ import { existsSync } from 'node:fs';
10
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { validateIssuePath, type WarnFn } from './issue-writer.js';
13
+ import type { Issue } from './types.js';
14
+
15
+ /** Create the board's or PM dir's `out/` directory if it doesn't exist. */
16
+ export async function ensureOutputDirs(pmDir: string | null, boardDir: string | null): Promise<void> {
17
+ if (boardDir) {
18
+ const boardOutDir = join(boardDir, 'out');
19
+ if (!existsSync(boardOutDir)) await mkdir(boardOutDir, { recursive: true });
20
+ return;
21
+ }
22
+ if (pmDir) {
23
+ const outDir = join(pmDir, 'out');
24
+ if (!existsSync(outDir)) await mkdir(outDir, { recursive: true });
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Append a wave summary section to progress.md (creating the file with a
30
+ * `# Board Progress` header if missing). Reads each issue's on-disk status
31
+ * to partition completed vs failed.
32
+ */
33
+ export async function appendProgressEntry(
34
+ pmDir: string | null,
35
+ boardDir: string | null,
36
+ issues: Issue[],
37
+ waveStart: number,
38
+ warn: WarnFn,
39
+ ): Promise<void> {
40
+ if (!pmDir) return;
41
+
42
+ const progressPath = boardDir ? join(boardDir, 'progress.md') : join(pmDir, 'progress.md');
43
+ const durationMin = Math.round((Date.now() - waveStart) / 60_000);
44
+ const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 16);
45
+
46
+ const { completed, failed } = await partitionByOnDiskStatus(pmDir, issues);
47
+
48
+ const lines = [
49
+ '',
50
+ `## ${timestamp} — Wave [${issues.map(i => i.id).join(', ')}]`,
51
+ '',
52
+ `- **Duration**: ${durationMin} min`,
53
+ `- **Completed**: ${completed.length}/${issues.length}${completed.length > 0 ? ` (${completed.join(', ')})` : ''}`,
54
+ ];
55
+ if (failed.length > 0) {
56
+ lines.push(`- **Failed**: ${failed.join(', ')}`);
57
+ }
58
+ lines.push('');
59
+
60
+ await writeProgressLines(progressPath, lines, warn);
61
+ }
62
+
63
+ async function partitionByOnDiskStatus(
64
+ pmDir: string,
65
+ issues: Issue[],
66
+ ): Promise<{ completed: string[]; failed: string[] }> {
67
+ const completed: string[] = [];
68
+ const failed: string[] = [];
69
+ for (const issue of issues) {
70
+ try {
71
+ const content = await readFile(validateIssuePath(issue.path, pmDir), 'utf-8');
72
+ const statusMatch = content.match(/^status:\s*(\S+)/m);
73
+ if (statusMatch?.[1] === 'done') completed.push(issue.id);
74
+ else failed.push(issue.id);
75
+ } catch {
76
+ failed.push(issue.id);
77
+ }
78
+ }
79
+ return { completed, failed };
80
+ }
81
+
82
+ async function writeProgressLines(filePath: string, lines: string[], warn: WarnFn): Promise<void> {
83
+ try {
84
+ if (existsSync(filePath)) {
85
+ await appendFile(filePath, `\n${lines.join('\n')}`, 'utf-8');
86
+ } else {
87
+ await writeFile(filePath, `# Board Progress\n${lines.join('\n')}`, 'utf-8');
88
+ }
89
+ } catch (err) {
90
+ warn(`Warning: failed to write progress log: ${err instanceof Error ? err.message : String(err)}`);
91
+ }
92
+ }
@@ -28,23 +28,40 @@ export interface CoordinatorPromptOptions {
28
28
  */
29
29
  export function buildCoordinatorPrompt(options: CoordinatorPromptOptions): string {
30
30
  const { issues, workingDir, pmDir, boardDir, existingDocs, resolveOutputPath } = options;
31
- const outDir = boardDir ? join(boardDir, 'out') : pmDir ? join(pmDir, 'out') : join(workingDir, '.mstro', 'pm', 'out');
31
+ const outDir = resolveOutDir(workingDir, pmDir, boardDir);
32
+ const teamName = `pm-wave-${Date.now()}`;
33
+
34
+ const issueBlocks = issues.map(issue => buildIssueBlock(issue, existingDocs, resolveOutputPath)).join('\n\n---\n\n');
35
+ const teammateSpawns = issues.map(issue => buildTeammateSpawn(issue, teamName, pmDir, existingDocs, resolveOutputPath)).join('\n\n');
36
+
37
+ return assembleCoordinatorPrompt({ issues, workingDir, pmDir, outDir, teamName, issueBlocks, teammateSpawns });
38
+ }
39
+
40
+ function resolveOutDir(workingDir: string, pmDir: string | null, boardDir: string | null): string {
41
+ if (boardDir) return join(boardDir, 'out');
42
+ if (pmDir) return join(pmDir, 'out');
43
+ return join(workingDir, '.mstro', 'pm', 'out');
44
+ }
32
45
 
33
- const issueBlocks = issues.map(issue => {
34
- const criteria = issue.acceptanceCriteria
35
- .map(c => `- [${c.checked ? 'x' : ' '}] ${c.text}`)
36
- .join('\n');
46
+ function buildIssueBlock(
47
+ issue: Issue,
48
+ existingDocs: string[],
49
+ resolveOutputPath: (issue: Issue) => string,
50
+ ): string {
51
+ const criteria = issue.acceptanceCriteria
52
+ .map(c => `- [${c.checked ? 'x' : ' '}] ${c.text}`)
53
+ .join('\n');
37
54
 
38
- const files = issue.filesToModify.length > 0
39
- ? `\nFiles to modify:\n${issue.filesToModify.map(f => `- ${f}`).join('\n')}`
40
- : '';
55
+ const files = issue.filesToModify.length > 0
56
+ ? `\nFiles to modify:\n${issue.filesToModify.map(f => `- ${f}`).join('\n')}`
57
+ : '';
41
58
 
42
- const predecessorDocs = resolvePredecessorDocs(issue, existingDocs);
43
- const predecessorSection = predecessorDocs.length > 0
44
- ? `\nPredecessor outputs to read:\n${predecessorDocs.map(d => `- ${d}`).join('\n')}`
45
- : '';
59
+ const predecessorDocs = resolvePredecessorDocs(issue, existingDocs);
60
+ const predecessorSection = predecessorDocs.length > 0
61
+ ? `\nPredecessor outputs to read:\n${predecessorDocs.map(d => `- ${d}`).join('\n')}`
62
+ : '';
46
63
 
47
- return `### ${issue.id}: ${issue.title}
64
+ return `### ${issue.id}: ${issue.title}
48
65
 
49
66
  **Type**: ${issue.type} | **Priority**: ${issue.priority} | **Estimate**: ${issue.estimate ?? 'unestimated'}
50
67
 
@@ -59,32 +76,53 @@ ${issue.technicalNotes || 'None'}
59
76
  ${files}${predecessorSection}
60
77
 
61
78
  **Output file**: ${resolveOutputPath(issue)}`;
62
- }).join('\n\n---\n\n');
63
-
64
- const teamName = `pm-wave-${Date.now()}`;
65
-
66
- const teammateSpawns = issues.map(issue => {
67
- const predecessorDocs = resolvePredecessorDocs(issue, existingDocs);
68
- const predInstr = predecessorDocs.length > 0
69
- ? `Read these predecessor output docs before starting: ${predecessorDocs.join(', ')}. `
70
- : '';
71
-
72
- const outputFile = resolveOutputPath(issue);
73
-
74
- const fileOwnership = issue.filesToModify.length > 0
75
- ? `\n> FILE OWNERSHIP: You own these files exclusively: ${issue.filesToModify.join(', ')}. Other teammates own all other files.`
76
- : '';
79
+ }
77
80
 
78
- return `Spawn teammate **${issue.id.toLowerCase()}** using the **Agent** tool with \`team_name: "${teamName}"\` and \`name: "${issue.id.toLowerCase()}"\`:
81
+ function buildTeammateSpawn(
82
+ issue: Issue,
83
+ teamName: string,
84
+ pmDir: string | null,
85
+ existingDocs: string[],
86
+ resolveOutputPath: (issue: Issue) => string,
87
+ ): string {
88
+ const predecessorDocs = resolvePredecessorDocs(issue, existingDocs);
89
+ const predInstr = predecessorDocs.length > 0
90
+ ? `Read these predecessor output docs before starting: ${predecessorDocs.join(', ')}. `
91
+ : '';
92
+
93
+ const outputFile = resolveOutputPath(issue);
94
+
95
+ const fileOwnership = issue.filesToModify.length > 0
96
+ ? `\n> FILE OWNERSHIP: You own these files exclusively: ${issue.filesToModify.join(', ')}. Other teammates own all other files.`
97
+ : '';
98
+
99
+ return `Spawn teammate **${issue.id.toLowerCase()}** using the **Agent** tool with \`team_name: "${teamName}"\` and \`name: "${issue.id.toLowerCase()}"\`:
79
100
  > ${predInstr}Work on issue ${issue.id}: ${issue.title}.
80
101
  > Read the full spec at ${pmDir ? join(pmDir, issue.path) : issue.path}.
81
102
  > Execute all acceptance criteria.
82
103
  > Write all output and results to ${outputFile} — this is the handoff artifact for downstream issues.
83
104
  > After writing output, update the issue front matter: change \`status: in_progress\` to \`status: done\`.
84
105
  > The orchestrator manages STATE.md. Stay within this issue's scope.${fileOwnership}`;
85
- }).join('\n\n');
106
+ }
107
+
108
+ interface AssembleArgs {
109
+ issues: Issue[];
110
+ workingDir: string;
111
+ pmDir: string | null;
112
+ outDir: string;
113
+ teamName: string;
114
+ issueBlocks: string;
115
+ teammateSpawns: string;
116
+ }
117
+
118
+ function assembleCoordinatorPrompt(args: AssembleArgs): string {
119
+ const { issues, workingDir, pmDir, outDir, teamName, issueBlocks, teammateSpawns } = args;
120
+ const issueCount = issues.length;
121
+ const plural = issueCount > 1 ? 's' : '';
122
+ const checklist = issues.map(i => `- [ ] ${i.id.toLowerCase()}`).join('\n');
123
+ const teammateNames = issues.map(i => `- \`${i.id.toLowerCase()}\``).join('\n');
86
124
 
87
- return `You are the team lead coordinating ${issues.length} issue${issues.length > 1 ? 's' : ''} using Agent Teams.
125
+ return `You are the team lead coordinating ${issueCount} issue${plural} using Agent Teams.
88
126
 
89
127
  ## Project Directory
90
128
  Working directory: ${workingDir}
@@ -102,7 +140,7 @@ All team coordination uses exactly two tools:
102
140
 
103
141
  ### Step 1: Spawn all teammates in one message
104
142
 
105
- Send a single message containing ${issues.length} **Agent** tool calls. Include \`team_name: "${teamName}"\` and a unique \`name\` in each call. The team starts automatically when the first teammate is spawned — the \`team_name\` parameter handles all setup.
143
+ Send a single message containing ${issueCount} **Agent** tool calls. Include \`team_name: "${teamName}"\` and a unique \`name\` in each call. The team starts automatically when the first teammate is spawned — the \`team_name\` parameter handles all setup.
106
144
 
107
145
  ${teammateSpawns}
108
146
 
@@ -113,10 +151,10 @@ After spawning, idle notifications arrive automatically as messages — you will
113
151
  Your first action after spawning all teammates: output a brief status message listing all teammates and confirming you are waiting for their idle notifications. Then wait.
114
152
 
115
153
  Track completion against this checklist — proceed to Step 3 only after all are checked:
116
- ${issues.map(i => `- [ ] ${i.id.toLowerCase()}`).join('\n')}
154
+ ${checklist}
117
155
 
118
156
  Exact teammate names for SendMessage (messages to any other name are silently dropped):
119
- ${issues.map(i => `- \`${i.id.toLowerCase()}\``).join('\n')}
157
+ ${teammateNames}
120
158
 
121
159
  When you receive an idle notification from a teammate:
122
160
  - Check off that teammate in the checklist above
@@ -143,7 +181,7 @@ After all outputs are verified:
143
181
  ## Coordination Rules
144
182
 
145
183
  - The team starts implicitly when you spawn the first teammate with \`team_name\`. Cleanup happens automatically when all teammates exit or the lead exits.
146
- - Wait for idle notifications from all ${issues.length} teammates before exiting — this ensures all work is saved to disk.
184
+ - Wait for idle notifications from all ${issueCount} teammates before exiting — this ensures all work is saved to disk.
147
185
  - Each teammate writes its output to disk (the handoff artifact for downstream issues). Research kept only in conversation is lost when the teammate exits.
148
186
  - Each teammate updates its issue front matter status to \`done\` when finished.
149
187
  - One issue per teammate — each teammate stays within its assigned scope.
@@ -0,0 +1,50 @@
1
+ // Copyright (c) 2025-present Mstro, Inc. All rights reserved.
2
+ // Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ /**
5
+ * Pure helpers for wave readiness: human-readable completion reasons
6
+ * and detection of "dead" issues stuck in non-terminal states.
7
+ */
8
+
9
+ import type { Issue } from './types.js';
10
+
11
+ const TERMINAL_STATUSES = new Set(['done', 'cancelled']);
12
+
13
+ /** Build the user-facing completion message shown when no issues remain. */
14
+ export function buildCompletionReason(issues: Issue[], epicScope: string | null): string {
15
+ const nonEpic = issues.filter(i => i.type !== 'epic');
16
+ const done = nonEpic.filter(i => TERMINAL_STATUSES.has(i.status)).length;
17
+ const blocked = nonEpic.filter(i => i.status === 'todo').length;
18
+ if (done === nonEpic.length) return epicScope ? 'All epic issues are done' : 'All issues are done';
19
+ if (blocked > 0) return `${done}/${nonEpic.length} issues done, ${blocked} blocked by incomplete dependencies`;
20
+ return epicScope ? 'All epic issues are done or blocked' : 'All work is done or blocked';
21
+ }
22
+
23
+ /**
24
+ * Detect issues stuck in non-terminal states with no path to completion.
25
+ * Returns a human-readable reason, or null when the board is healthy.
26
+ */
27
+ export function detectDeadState(issues: Issue[]): string | null {
28
+ const nonEpic = issues.filter(i => i.type !== 'epic');
29
+ const stuck = nonEpic.filter(i => !TERMINAL_STATUSES.has(i.status) && i.status !== 'todo');
30
+ if (stuck.length === 0) return null;
31
+
32
+ const stuckIds = stuck.map(i => `${i.id} (${i.status})`).join(', ');
33
+
34
+ const issueByPath = new Map(issues.map(i => [i.path, i]));
35
+ const blockedByStuck = nonEpic.filter(i => {
36
+ if (i.status !== 'todo') return false;
37
+ return i.blockedBy.some(bp => {
38
+ const blocker = issueByPath.get(bp);
39
+ return blocker && !TERMINAL_STATUSES.has(blocker.status);
40
+ });
41
+ });
42
+ const blockedIds = blockedByStuck.map(i => i.id).join(', ');
43
+
44
+ return `Board stuck: ${stuckIds} cannot progress${blockedIds ? `. Blocking: ${blockedIds}` : ''}`;
45
+ }
46
+
47
+ /** True iff any non-epic issue is stuck in a non-terminal, non-todo state. */
48
+ export function hasBlockedIssues(issues: Issue[]): boolean {
49
+ return issues.some(i => i.type !== 'epic' && !TERMINAL_STATUSES.has(i.status) && i.status !== 'todo');
50
+ }
@@ -13,16 +13,19 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
13
13
  import { join } from 'node:path';
14
14
  import { ResilientRunner } from '../../cli/headless/resilient-runner.js';
15
15
  import { loadAgentPrompt } from './agent-loader.js';
16
+ import { getBoardReviewCriteria, resolveActiveBoardId } from './board-config.js';
16
17
  import { resolveIsCodeTask } from './issue-classification.js';
18
+ import { appendCancellationNote, type WarnFn } from './issue-writer.js';
19
+ import { resolveOutputPath } from './output-manager.js';
17
20
  import type { Issue, ReviewCheck, ReviewResult } from './types.js';
18
21
 
19
22
  /** Max review attempts per issue per sprint before giving up */
20
23
  export const MAX_REVIEW_ATTEMPTS = 3;
21
24
 
22
- /** Review runner stall timeouts (ms) */
25
+ /** Review runner stall timeouts (ms) — hard cap is a backstop that only fires after stall signals flag the run */
23
26
  const REVIEW_STALL_WARNING_MS = 300_000; // 5 min
24
27
  const REVIEW_STALL_KILL_MS = 600_000; // 10 min
25
- const REVIEW_STALL_HARD_CAP_MS = 900_000; // 15 min
28
+ const REVIEW_STALL_HARD_CAP_MS = 2_700_000; // 45 min backstop
26
29
 
27
30
  export interface ReviewIssueOptions {
28
31
  workingDir: string;
@@ -244,3 +247,100 @@ function buildReviewPrompt(
244
247
  return `You are a reviewer. Review the work done for issue ${issue.id}: ${issue.title}.\n\n## Acceptance Criteria\n${criteriaStr}\n\nOutput EXACTLY one JSON object on its own line (no markdown fencing):\n{"passed": true, "checks": [{"name": "criteria_met", "passed": true, "details": "..."}]}`;
245
248
  }
246
249
 
250
+ // ── Review pipeline orchestration ───────────────────────────
251
+
252
+ /** Status messages emitted during the review pipeline. */
253
+ export type ReviewProgressStatus = 'reviewing' | 'passed' | 'failed' | 'max_attempts';
254
+
255
+ /** Callbacks for the executor to observe review pipeline events. */
256
+ export interface ReviewPipelineCallbacks {
257
+ /** Update an issue's front-matter status on disk. */
258
+ setStatus: (issuePath: string, status: string) => Promise<void>;
259
+ onOutput: (issueId: string, text: string) => void;
260
+ onReviewProgress: (issueId: string, status: ReviewProgressStatus) => void;
261
+ onIssueAbandoned: (issueId: string, reason: string, attempts: number) => void;
262
+ onIssueCompleted: (issue: Issue) => void;
263
+ onIssueError: (issueId: string, error: string) => void;
264
+ warn: WarnFn;
265
+ }
266
+
267
+ /** Configuration for running the full review pipeline for a single issue. */
268
+ export interface ReviewPipelineOptions {
269
+ issue: Issue;
270
+ pmDir: string;
271
+ workingDir: string;
272
+ executionDir: string | null;
273
+ boardDir: string | null;
274
+ boardId: string | null;
275
+ extraEnv?: Record<string, string>;
276
+ }
277
+
278
+ /**
279
+ * Full review pipeline for a completed issue: attempt-count guard, status
280
+ * transitions, AI review, persistence, and event emission through the
281
+ * caller's callbacks. Returns `true` when the review passes and the issue is
282
+ * marked `done`, `false` otherwise (reverted, cancelled, or errored).
283
+ */
284
+ export async function runReviewPipeline(
285
+ options: ReviewPipelineOptions,
286
+ callbacks: ReviewPipelineCallbacks,
287
+ ): Promise<boolean> {
288
+ const { issue, pmDir, workingDir, executionDir, boardDir, boardId, extraEnv } = options;
289
+ const reviewDir = boardDir ?? pmDir;
290
+ const attempts = getReviewAttemptCount(reviewDir, issue);
291
+
292
+ if (attempts >= MAX_REVIEW_ATTEMPTS) {
293
+ await callbacks.setStatus(issue.path, 'cancelled');
294
+ await appendCancellationNote(
295
+ pmDir,
296
+ issue,
297
+ `Cancelled after ${MAX_REVIEW_ATTEMPTS} failed reviews — issue may need restructuring`,
298
+ callbacks.warn,
299
+ );
300
+ callbacks.onReviewProgress(issue.id, 'max_attempts');
301
+ callbacks.onIssueAbandoned(
302
+ issue.id,
303
+ `Review failed ${MAX_REVIEW_ATTEMPTS} times — cancelled to unblock dependents`,
304
+ attempts,
305
+ );
306
+ callbacks.onOutput(issue.id, 'Review: max attempts reached, cancelling issue to unblock dependents');
307
+ return false;
308
+ }
309
+
310
+ await callbacks.setStatus(issue.path, 'in_review');
311
+ callbacks.onReviewProgress(issue.id, 'reviewing');
312
+
313
+ const outputPath = resolveOutputPath(issue, workingDir, boardDir);
314
+ const effectiveBoardId = boardId ?? resolveActiveBoardId(pmDir);
315
+ const reviewCriteria = await getBoardReviewCriteria(pmDir, effectiveBoardId, callbacks.warn);
316
+
317
+ const result = await reviewIssue({
318
+ workingDir: executionDir || workingDir,
319
+ issue,
320
+ pmDir,
321
+ outputPath,
322
+ onOutput: (text) => callbacks.onOutput(issue.id, text),
323
+ logDir: boardDir ? join(boardDir, 'logs') : undefined,
324
+ reviewCriteria,
325
+ boardDir,
326
+ extraEnv,
327
+ });
328
+ persistReviewResult(reviewDir, issue, result);
329
+
330
+ if (result.passed) {
331
+ await callbacks.setStatus(issue.path, 'done');
332
+ callbacks.onReviewProgress(issue.id, 'passed');
333
+ callbacks.onIssueCompleted(issue);
334
+ return true;
335
+ }
336
+
337
+ await callbacks.setStatus(issue.path, 'todo');
338
+ appendReviewFeedback(pmDir, issue, result);
339
+ callbacks.onReviewProgress(issue.id, 'failed');
340
+ callbacks.onIssueError(
341
+ issue.id,
342
+ `Review failed: ${result.checks.filter(c => !c.passed).map(c => c.name).join(', ')}`,
343
+ );
344
+ return false;
345
+ }
346
+